mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46bfb86c1b | |||
| 3549fe5ad8 | |||
| dd3ed4e284 | |||
| c794586cc7 | |||
| d9303032ca | |||
| 6da8b2b6ec | |||
| 861d61239a | |||
| c952d2fb3a | |||
| 129fa0aa4c | |||
| cf7246c71b | |||
| b4c2989285 | |||
| f2783713a5 | |||
| 5d60c4fbe1 | |||
| 62c56e0a79 | |||
| 2e845030de | |||
| 2269f2e2d5 | |||
| da7bef88f1 | |||
| f26014579b | |||
| 5041c722c9 | |||
| c56798e991 | |||
| 40e96f3592 | |||
| 0e24b53a5b | |||
| a820c49c94 | |||
| 538e64ffc0 | |||
| 0b23d6f7d7 | |||
| 9e6f80019d | |||
| a8fd01d4d8 | |||
| cbe2fc492e | |||
| ae370382d3 | |||
| e112d81697 | |||
| 11a27d3c67 | |||
| 8e513a1bae | |||
| f98be845d3 | |||
| 0c2acfdd8a | |||
| 777db8767f | |||
| 82f6047506 | |||
| 3ce320da5a | |||
| 7ab0db29ae | |||
| 006ea97200 | |||
| e569aedd3e | |||
| 35080aea2d | |||
| 85e588ffe9 | |||
| 03524f4a65 | |||
| 9e69e020ab | |||
| 14bbd3907d | |||
| ca8d8e92ba | |||
| 079474fa06 | |||
| 6e249a54f4 | |||
| fb798a4532 | |||
| a599f383f5 | |||
| 6bfa7f0166 |
@@ -7,6 +7,18 @@ on:
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
install-scripts:
|
||||
name: Test Installer Scripts (systemd and Alpine OpenRC)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run installer regression tests
|
||||
run: bash test-install-scripts-proxy.sh
|
||||
|
||||
- name: Test Alpine bootstrap and OpenRC lifecycle
|
||||
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
|
||||
|
||||
frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,10 +31,10 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
node-version-file: '.node-version'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
run: npm install -g pnpm@10.28.1
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -42,7 +54,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: 'go-backend/go.mod'
|
||||
cache-dependency-path: go-backend/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
@@ -77,7 +89,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: 'go-backend/go.mod'
|
||||
cache-dependency-path: go-backend/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
@@ -100,7 +112,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: 'go-gost/go.mod'
|
||||
cache-dependency-path: go-gost/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: 'go-gost/go.mod'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: 'go-backend/go.mod'
|
||||
|
||||
- name: Cache Go dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -229,6 +229,7 @@ jobs:
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg KEYGEN_ACCOUNT_ID=${{ secrets.KEYGEN_ACCOUNT_ID }} \
|
||||
--push \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
|
||||
@@ -303,6 +304,9 @@ jobs:
|
||||
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
|
||||
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
|
||||
|
||||
- name: Verify release installer on Alpine OpenRC
|
||||
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh /workspace/artifacts/install.sh
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -431,4 +435,3 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.node-version'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Get version from tag
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
name: UDP Destination Regression
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'fix/udp-wildcard-source', 'pr/udp-wildcard-source']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'go-gost/**'
|
||||
- '.github/workflows/udp-destination.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: udp-destination-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go-gost/go.mod'
|
||||
cache-dependency-path: |
|
||||
go-gost/go.sum
|
||||
go-gost/x/go.sum
|
||||
|
||||
- name: Test agent module
|
||||
working-directory: go-gost
|
||||
run: go test ./...
|
||||
|
||||
- name: Test protocol module
|
||||
working-directory: go-gost/x
|
||||
run: go test ./...
|
||||
|
||||
- name: Check UDP races
|
||||
working-directory: go-gost/x
|
||||
run: go test -race ./internal/net/udp ./listener/udp
|
||||
|
||||
- name: Vet UDP implementation
|
||||
working-directory: go-gost/x
|
||||
run: go vet ./internal/net/udp ./listener/udp
|
||||
|
||||
- name: Build destination regression executable
|
||||
working-directory: go-gost/x
|
||||
run: |
|
||||
go test -c -race -o "$RUNNER_TEMP/udp-destination.test" ./listener/udp
|
||||
go test -c -race -o "$RUNNER_TEMP/udp-packet.test" ./internal/net/udp
|
||||
|
||||
- name: Test multiple destinations in isolated network namespace
|
||||
run: |
|
||||
sudo unshare --net -- sh -eu -c '
|
||||
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
|
||||
ip link set lo up
|
||||
ip addr add 198.18.0.1/32 dev lo
|
||||
ip addr add 169.254.1.2/32 dev lo
|
||||
ip -6 addr add fd00:1::1/128 dev lo nodad
|
||||
ip -6 addr add fd00:2::1/128 dev lo nodad
|
||||
ip -6 addr add fd00:3::1/128 dev lo nodad
|
||||
for suffix in "" 2; do
|
||||
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
|
||||
ip link set "mc-tx$suffix" addrgenmode none up
|
||||
ip link set "mc-rx$suffix" addrgenmode none up
|
||||
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
|
||||
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
|
||||
done
|
||||
ip addr add 198.18.1.1/24 dev mc-tx
|
||||
ip addr add 198.18.2.1/24 dev mc-tx2
|
||||
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
|
||||
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
|
||||
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
|
||||
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
|
||||
"$1" -test.v -test.timeout=60s -test.count=5
|
||||
exec "$2" -test.v -test.timeout=60s -test.count=5
|
||||
' sh "$RUNNER_TEMP/udp-destination.test" "$RUNNER_TEMP/udp-packet.test"
|
||||
|
||||
- name: Build Linux agent
|
||||
working-directory: go-gost
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: go build -trimpath -o "$RUNNER_TEMP/flux-agent" .
|
||||
@@ -0,0 +1 @@
|
||||
22.23.4
|
||||
@@ -40,7 +40,8 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
|
||||
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
|
||||
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
|
||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
||||
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
|
||||
- **Go versions**: all three modules require Go 1.26.10 or newer (CVE-2026-111943); CI reads the module's `go.mod`. Do not use Go 1.27.0–1.27.2, which also lack this fix.
|
||||
- **Node.js versions**: `.node-version` pins 22.23.4 for CI; local builds also support patched 24.22.1+ and 26.12.1+ (CVE-2026-111497).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
- 支持隧道修改配置、转发修改隧道
|
||||
|
||||
|
||||
## 构建环境
|
||||
|
||||
Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27,须为 **1.27.3 或更新补丁版**。前端与 skill 发布使用 **Node.js 22.23.4**(见 `.node-version`),本地也支持 24.22.1+ 或 26.12.1+。前端使用 `pnpm@10.28.1`。
|
||||
|
||||
这些版本包含 2026 年 10 月的运行时安全修复。已有部署还需重新构建并更新后端镜像及所有 agent 二进制,详见 [安全排查与升级说明](docs/security/2026-10-runtime-updates.md)。
|
||||
|
||||
## 部署流程
|
||||
---
|
||||
### Docker Compose部署
|
||||
@@ -71,6 +77,45 @@ docker compose up -d
|
||||
|
||||
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
|
||||
|
||||
#### 通行证密钥登录(可选)
|
||||
|
||||
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
|
||||
|
||||
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
|
||||
|
||||
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
|
||||
|
||||
```dotenv
|
||||
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
|
||||
```
|
||||
|
||||
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
|
||||
|
||||
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
|
||||
|
||||
```yaml
|
||||
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
|
||||
```
|
||||
|
||||
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
|
||||
|
||||
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
|
||||
|
||||
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
|
||||
|
||||
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
|
||||
|
||||
**入口未显示时,按顺序检查:**
|
||||
|
||||
1. 在部署目录运行 `docker compose exec backend printenv FLVX_WEBAUTHN_ORIGIN`,仅检查这一项是否为预期的公开前端 origin;不要贴出完整 `docker compose config` 或其他环境变量,以免泄露密码和密钥。若为空,检查 `.env` 和实际 Compose 模板的 `backend.environment`,然后按步骤 2 重建后端。
|
||||
2. 向浏览器实际调用的后端地址发送公开状态请求,例如将以下示例地址替换为自己的 API 地址后运行 `curl -sS -X POST 'https://api.example.com/api/v1/user/passkey/status' -H 'Content-Type: application/json' -d '{}'`。应返回 `code: 0` 且 `data.enabled: true`;若为 `false`,后端尚未接受有效配置。此接口无需登录或提供密码。
|
||||
3. 在访问面板的浏览器控制台检查 `window.isSecureContext` 为 `true`,且 `typeof window.PublicKeyCredential` 不是 `"undefined"`。普通公网 HTTP 页面或不支持 WebAuthn 的浏览器不会显示入口。确认后刷新页面,并查看 `/api/v1/user/passkey/status` 的实际网络响应。
|
||||
4. 若状态已启用且浏览器满足条件,检查 `docker compose images frontend` 中的前端镜像版本,并按步骤 2 的 PWA 提示刷新或使用无痕窗口。登录页按钮与个人资料页卡片均不存在时,通常是旧前端页面或状态请求未成功;只有其中一个缺失时,记录页面地址和该请求的响应再排查。
|
||||
|
||||
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人资料页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
|
||||
|
||||
建议保留至少一种可用的密码恢复途径。数据库级备份会保留密钥记录;当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
|
||||
|
||||
#### 从 SQLite 迁移到 PostgreSQL
|
||||
|
||||
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
|
||||
|
||||
+10
-1
@@ -64,6 +64,14 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
Alpine Linux 最小化安装若未包含 `curl`,可使用系统自带的 `wget` 下载:
|
||||
|
||||
```bash
|
||||
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
脚本会在 Alpine 上自动安装 Bash、`curl` 和 CA 证书,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
|
||||
|
||||
**安装过程中会提示输入:**
|
||||
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
|
||||
- **密钥**: 刚才在面板中获取的节点密钥。
|
||||
@@ -77,7 +85,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
|
||||
|
||||
### 3. 验证安装
|
||||
安装完成后,服务会自动启动。
|
||||
- 查看状态: `systemctl status flux_agent`
|
||||
- systemd 查看状态: `systemctl status flux_agent`
|
||||
- Alpine/OpenRC 查看状态: `rc-service flux_agent status`
|
||||
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
FLUX_VERSION: ${FLUX_VERSION:-dev}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-10-22 运行时漏洞排查与升级
|
||||
|
||||
## 排查结论
|
||||
|
||||
| 漏洞 | 本项目涉及范围 | 修复措施 |
|
||||
| --- | --- | --- |
|
||||
| CVE-2026-109618、CVE-2026-109619(PHP RCE) | 仓库无 PHP 源码、Composer 依赖或 PHP 容器。项目后端为 Go,前端生产容器为 Nginx 静态服务。 | 本项目无 PHP 运行时需要更新;同机部署的其他 PHP 服务需单独排查。转发 PHP 网站的流量不会修复目标网站的 PHP。 |
|
||||
| CVE-2026-111497(Node.js Brotli 越界写入) | 前端 Docker 构建使用 Node.js 22;CI 原使用已停止支持的 Node.js 20。Node.js 用于构建、开发及 skill 安装/发布,不是面板生产服务端。未发现项目源码直接调用 Brotli 解码,但构建依赖和下载工具仍运行于 Node.js。 | Docker、CI 和 `.node-version` 固定为 22.23.4;包声明仅接受已修复的 22.23.4+、24.22.1+、26.12.1+ 分支,前端启用 `engine-strict`。 |
|
||||
| CVE-2026-111943(Go crypto/x509 中间证书信任) | 后端的版本检查、升级下载、授权验证等使用 HTTPS;agent 的更新下载、TLS 拨号和证书验证使用 Go 标准库。原 Go 1.25 工具链缺少此次拒绝信任修复。 | 三个 Go 模块最低版本升至 1.26.10,CI 从对应 `go.mod` 读取,后端 Docker 使用 1.26.10-bookworm。 |
|
||||
|
||||
Go 漏洞涉及一组特定中间证书,并非拒绝所有包含八层中间证书的证书链。官方修复在 `crypto/x509.Certificate.Verify` 中拒绝包含这些证书的链。Go 1.27.0–1.27.2 也未修复;手动选择 Go 1.27 时必须使用 1.27.3+。`go.mod` 的最低版本无法排除该较新分支里的旧补丁版本,因此发布必须使用仓库固定的工具链。
|
||||
|
||||
## 让已部署实例获得修复
|
||||
|
||||
1. 使用本次修改后的构建配置发布新版本,重新构建后端及前端镜像、Linux amd64/arm64 agent。Go 标准库编译进二进制,仅更新宿主机 Go 或 `ca-certificates` 不会替换已有二进制中的标准库。已有镜像标签也不会因源码更新自动修复。
|
||||
2. 在面板实际部署目录,将 `.env` 中的 `FLUX_VERSION` 设置为包含本次修复的**已发布版本**,再执行 `docker compose pull backend frontend` 和 `docker compose up -d backend frontend`。如使用独立构建或私有仓库,应部署对应的新镜像。
|
||||
3. 将所有节点的 agent 更新为同一安全版本并重启服务,包括离线节点。使用解压后的构建产物执行 `go version -m /path/to/gost` 或 `go version -m /path/to/paneld`,核对构建版本为 go1.26.10(或已修复的更新版本);UPX 压缩可能需要先对副本解压以读取构建信息。
|
||||
4. 开发机和自托管构建机也需升级 Node.js。本次排查时本机为 v26.0.0,属于受影响版本;应升级至 26.12.1+,或切换到 `.node-version` 的 22.23.4。无需在运行静态前端的 Nginx 容器内安装 Node.js。
|
||||
|
||||
本次代码修改不表示远端部署已经更新;须完成新版本发布、镜像替换与节点升级后,才能确认线上获得修复。更新系统 CA 信任包可作为额外措施,但不能替代 Go 二进制重建。
|
||||
|
||||
## 本次验证
|
||||
|
||||
- Go 1.26.10 下,`go-backend`、`go-gost`、`go-gost/x` 各自的 `go test ./...` 全部通过。PostgreSQL 外部数据库契约测试仍需配置 `FLVX_POSTGRES_TEST_DSN` 后单独执行。
|
||||
- Node.js 22.23.4、pnpm 10.28.1 下,前端 `pnpm install --frozen-lockfile` 与 `pnpm run build` 通过,无需更改依赖锁文件。
|
||||
- 本机 Node.js 26.0.0 执行前端构建时,被 `engine-strict` 拒绝。
|
||||
- 使用 `CGO_ENABLED=0` 交叉编译后端和 agent 的 Linux amd64/arm64 共四个二进制成功;`go version -m` 确认均为 go1.26.10。
|
||||
- Docker Hub 上两个固定构建镜像标签均存在,且包含 Linux amd64/arm64。未执行 Docker 镜像构建或线上部署。
|
||||
- 工作流 YAML、包 JSON 解析及 `git diff --check` 通过。
|
||||
|
||||
## 官方依据
|
||||
|
||||
- [PHP 官方安全公告](https://www.php.net/archive/2026.php#2026-10-21-4)
|
||||
- [Node.js 2026-10-21 安全更新及修复版本](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
|
||||
- [Go 官方问题 #82345](https://go.dev/issue/82345)
|
||||
- [Go 漏洞库 GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809)
|
||||
- [Go 官方发布记录](https://go.dev/doc/devel/release)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,650 @@
|
||||
# Forward Flow Reset Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a permission-checked action that resets only one forward rule's displayed upload and download counters.
|
||||
|
||||
**Architecture:** A dedicated repository method updates only the selected `forward` row. A dedicated authenticated handler reuses `resolveForwardAccess`, and the React page calls the endpoint from all three rule views through one confirmation modal.
|
||||
|
||||
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL-compatible models, React, TypeScript, shadcn bridge components, Tailwind CSS v4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Only `forward.in_flow`, `forward.out_flow`, and `forward.updated_time` may change during reset.
|
||||
- Do not modify `user`, `user_tunnel`, quota, historical statistics, nftables counter state, or running services.
|
||||
- Administrators may reset any rule; non-admin users may reset only their own rules through existing `resolveForwardAccess` behavior.
|
||||
- All API responses must keep the `{code, msg, data, ts}` envelope.
|
||||
- Frontend imports must use `src/shadcn-bridge/heroui/*`; do not add `@heroui/*` or `@nextui-org/*` dependencies.
|
||||
- Do not add frontend test infrastructure.
|
||||
- Do not edit generated protobuf files, `install.sh`, or `panel_install.sh`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the repository flow-reset primitive
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository_mutations.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `model.Forward`, the repository's GORM database handle, and an explicit Unix-millisecond timestamp.
|
||||
- Produces: `func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error`.
|
||||
|
||||
- [ ] **Step 1: Write the failing repository tests**
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`:
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
const originalUpdated int64 = 1000
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES
|
||||
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
|
||||
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
|
||||
`, originalUpdated, originalUpdated).Error; err != nil {
|
||||
t.Fatalf("insert forwards: %v", err)
|
||||
}
|
||||
|
||||
const resetAt int64 = 2000
|
||||
if err := r.ResetForwardFlow(20, resetAt); err != nil {
|
||||
t.Fatalf("ResetForwardFlow: %v", err)
|
||||
}
|
||||
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
|
||||
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||
}
|
||||
|
||||
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
|
||||
var r *Repository
|
||||
if err := r.ResetForwardFlow(20, 2000); err == nil {
|
||||
t.Fatal("expected uninitialized repository error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("query %q: %v", query, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the repository tests and verify the missing method failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go-backend && go test ./internal/store/repo -run TestResetForwardFlow -count=1
|
||||
```
|
||||
|
||||
Expected: compilation fails because `ResetForwardFlow` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal repository method**
|
||||
|
||||
Add to the flow-reset section of `go-backend/internal/store/repo/repository_mutations.go`:
|
||||
|
||||
```go
|
||||
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Forward{}).
|
||||
Where("id = ?", forwardID).
|
||||
Updates(map[string]interface{}{
|
||||
"in_flow": 0,
|
||||
"out_flow": 0,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
```
|
||||
|
||||
The file already imports `errors` and `model`; do not add a new dependency.
|
||||
|
||||
- [ ] **Step 4: Format and run the focused repository tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go-backend && gofmt -w internal/store/repo/repository_forward_flow_reset_test.go internal/store/repo/repository_mutations.go
|
||||
go test ./internal/store/repo -run TestResetForwardFlow -count=1
|
||||
```
|
||||
|
||||
Expected: both reset tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit the repository change**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/store/repo/repository_mutations.go go-backend/internal/store/repo/repository_forward_flow_reset_test.go
|
||||
git commit -m "feat: add forward flow reset repository method"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the authenticated reset endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/forward_reset_flow_test.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST` JSON `{ "id": number }`, `resolveForwardAccess`, and `Repository.ResetForwardFlow` from Task 1.
|
||||
- Produces: `POST /api/v1/forward/reset-flow` and `func (h *Handler) forwardResetFlow(http.ResponseWriter, *http.Request)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing handler tests**
|
||||
|
||||
Create `go-backend/internal/http/handler/forward_reset_flow_test.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actorID int64
|
||||
actorRole int
|
||||
forwardID int64
|
||||
wantCode int
|
||||
wantInFlow int64
|
||||
wantOutFlow int64
|
||||
}{
|
||||
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, r := setupForwardResetFlowHandler(t)
|
||||
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.forwardResetFlow(res, req)
|
||||
|
||||
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
|
||||
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
|
||||
}
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
|
||||
h, _ := setupForwardResetFlowHandler(t)
|
||||
|
||||
t.Run("non post", func(t *testing.T) {
|
||||
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardResetFlow(res, req)
|
||||
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||
t.Fatalf("code = %d, want -1", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid id", func(t *testing.T) {
|
||||
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardResetFlow(res, req)
|
||||
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||
t.Fatalf("code = %d, want -1", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
statements := []string{
|
||||
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(1, 'admin', 'pwd', 0, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
|
||||
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
|
||||
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
|
||||
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := r.DB().Exec(statement).Error; err != nil {
|
||||
t.Fatalf("seed database: %v", err)
|
||||
}
|
||||
}
|
||||
return New(r, "test-secret"), r
|
||||
}
|
||||
|
||||
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]int64{"id": forwardID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
|
||||
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
|
||||
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
|
||||
}
|
||||
|
||||
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
|
||||
}
|
||||
return payload.Code
|
||||
}
|
||||
|
||||
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("query %q: %v", query, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the project's default error code differs from `-1`, replace the test expectation with the actual `response.ErrDefault` code after inspecting one existing handler response; do not weaken the success and database assertions.
|
||||
|
||||
- [ ] **Step 2: Run the handler tests and verify the missing handler failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go-backend && go test ./internal/http/handler -run TestForwardResetFlow -count=1
|
||||
```
|
||||
|
||||
Expected: compilation fails because `forwardResetFlow` is undefined.
|
||||
|
||||
- [ ] **Step 3: Register and implement the endpoint**
|
||||
|
||||
Add this route beside the other forward routes in `go-backend/internal/http/handler/handler.go`:
|
||||
|
||||
```go
|
||||
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
|
||||
```
|
||||
|
||||
Add this handler beside `forwardPause` and `forwardResume` in `go-backend/internal/http/handler/mutations.go`:
|
||||
|
||||
```go
|
||||
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
|
||||
id := idFromBody(r, w)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
|
||||
if errors.Is(err, errForwardNotFound) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
```
|
||||
|
||||
This deliberately does not call runtime service controls or nftables reconciliation.
|
||||
|
||||
- [ ] **Step 4: Format and run the focused handler tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go-backend && gofmt -w internal/http/handler/forward_reset_flow_test.go internal/http/handler/handler.go internal/http/handler/mutations.go
|
||||
go test ./internal/http/handler -run TestForwardResetFlow -count=1
|
||||
```
|
||||
|
||||
Expected: all reset endpoint tests pass.
|
||||
|
||||
- [ ] **Step 5: Run all backend tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go-backend && go test ./...
|
||||
```
|
||||
|
||||
Expected: all backend packages and contract tests pass, excluding environment-gated PostgreSQL tests when `FLVX_POSTGRES_TEST_DSN` is unset.
|
||||
|
||||
- [ ] **Step 6: Commit the endpoint change**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/forward_reset_flow_test.go
|
||||
git commit -m "feat: add forward flow reset endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add the rule-page reset action and confirmation modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST /forward/reset-flow`, the page's `Forward` shape, `refreshForwardList`, toast notifications, and existing modal/button bridge components.
|
||||
- Produces: `resetForwardFlow(id: number)`, a shared reset handler, disabled zero-usage actions in all rule views, and one confirmation modal.
|
||||
|
||||
- [ ] **Step 1: Add the frontend API wrapper**
|
||||
|
||||
Add beside the forward control operations in `vite-frontend/src/api/index.ts`:
|
||||
|
||||
```ts
|
||||
export const resetForwardFlow = (forwardId: number) =>
|
||||
Network.post("/forward/reset-flow", { id: forwardId });
|
||||
```
|
||||
|
||||
Import `resetForwardFlow` from `@/api` in `vite-frontend/src/pages/forward.tsx`.
|
||||
|
||||
- [ ] **Step 2: Add page state and shared reset handlers**
|
||||
|
||||
Add state beside the existing delete modal state:
|
||||
|
||||
```ts
|
||||
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
|
||||
const [resetFlowLoading, setResetFlowLoading] = useState(false);
|
||||
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(null);
|
||||
```
|
||||
|
||||
Add these handlers beside `handleDelete` and `confirmDelete`:
|
||||
|
||||
```ts
|
||||
const handleResetFlow = (forward: Forward) => {
|
||||
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
|
||||
setForwardToResetFlow(forward);
|
||||
setResetFlowModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmResetFlow = async () => {
|
||||
if (!forwardToResetFlow) return;
|
||||
|
||||
setResetFlowLoading(true);
|
||||
try {
|
||||
const res = await resetForwardFlow(forwardToResetFlow.id);
|
||||
|
||||
if (res.code !== 0) {
|
||||
toast.error(res.msg || "流量清零失败");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("规则流量已清零");
|
||||
setResetFlowModalOpen(false);
|
||||
setForwardToResetFlow(null);
|
||||
await refreshForwardList(false);
|
||||
} catch {
|
||||
toast.error("流量清零失败");
|
||||
} finally {
|
||||
setResetFlowLoading(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add one reusable reset icon button to both table row components**
|
||||
|
||||
Pass `handleResetFlow` into `SortableTableRow` and `SortableCompactTableRow` at every render site. Add it to each component's destructured props.
|
||||
|
||||
Insert this button between diagnosis and delete in each table action cell:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
|
||||
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||
size="sm"
|
||||
title="流量清零"
|
||||
onPress={() => handleResetFlow(forward)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the reset action to the card view**
|
||||
|
||||
Insert a fourth action button between diagnosis and delete in `renderForwardCard`:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="secondary"
|
||||
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(forward)}
|
||||
>
|
||||
清零
|
||||
</Button>
|
||||
```
|
||||
|
||||
Change the card action container from `flex gap-1.5 mt-3` to `grid grid-cols-2 gap-1.5 mt-3` so all four actions remain readable at the smallest supported card width.
|
||||
|
||||
- [ ] **Step 5: Add the confirmation modal**
|
||||
|
||||
Add beside the delete confirmation modal:
|
||||
|
||||
```tsx
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
classNames={{
|
||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||
}}
|
||||
isOpen={resetFlowModalOpen}
|
||||
placement="center"
|
||||
scrollBehavior="inside"
|
||||
size="lg"
|
||||
onOpenChange={setResetFlowModalOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-secondary">确认流量清零</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要清零规则{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
"{forwardToResetFlow?.name}"
|
||||
</span>{" "}
|
||||
当前显示的上传和下载流量吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button isDisabled={resetFlowLoading} variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
isLoading={resetFlowLoading}
|
||||
onPress={confirmResetFlow}
|
||||
>
|
||||
确认清零
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
Add this wrapper beside the other reset handlers and pass it to the modal as `onOpenChange={handleResetFlowModalOpenChange}`:
|
||||
|
||||
```ts
|
||||
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
|
||||
if (resetFlowLoading) return;
|
||||
setResetFlowModalOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setForwardToResetFlow(null);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Format and verify the frontend**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd vite-frontend && pnpm exec prettier --write src/api/index.ts src/pages/forward.tsx
|
||||
pnpm run build
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
Expected: TypeScript/Vite build succeeds and ESLint finishes without errors.
|
||||
|
||||
- [ ] **Step 7: Commit the frontend change**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/api/index.ts vite-frontend/src/pages/forward.tsx
|
||||
git commit -m "feat: add forward flow reset action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Perform integrated verification
|
||||
|
||||
**Files:**
|
||||
- Verify only; no planned source changes.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the repository method, API endpoint, and rule-page action from Tasks 1-3.
|
||||
- Produces: evidence that the complete feature builds and all affected tests pass.
|
||||
|
||||
- [ ] **Step 1: Run the complete backend suite**
|
||||
|
||||
```bash
|
||||
cd go-backend && go test ./...
|
||||
```
|
||||
|
||||
Expected: all available backend tests pass.
|
||||
|
||||
- [ ] **Step 2: Run the complete frontend checks**
|
||||
|
||||
```bash
|
||||
cd vite-frontend && pnpm run build && pnpm run lint
|
||||
```
|
||||
|
||||
Expected: both commands exit successfully.
|
||||
|
||||
- [ ] **Step 3: Check formatting and working-tree scope**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git log -4 --oneline
|
||||
```
|
||||
|
||||
Expected: no whitespace errors; the working tree is clean; the three feature commits are visible after the design and implementation-plan commits.
|
||||
|
||||
- [ ] **Step 4: Manually verify the feature when a local panel is available**
|
||||
|
||||
1. Open the Rules page as an administrator and reset a rule with non-zero upload/download traffic.
|
||||
2. Confirm the modal states that user totals, tunnel quota, and history are unaffected.
|
||||
3. Confirm the rule immediately shows zero after success.
|
||||
4. Confirm the user page's total traffic and user-tunnel traffic values did not change.
|
||||
5. Generate new traffic and confirm the rule starts accumulating from zero.
|
||||
6. Log in as a normal user and confirm the user can reset an owned rule but cannot access another user's rule through a direct API request.
|
||||
|
||||
Expected: all six checks match the design specification.
|
||||
@@ -0,0 +1,407 @@
|
||||
# nftables 纯转发设计
|
||||
|
||||
**日期**: 2026-05-30
|
||||
**状态**: 待审核
|
||||
**作者**: Codex
|
||||
|
||||
## 概述
|
||||
|
||||
为 FLVX 增加一种不依赖 agent 的纯转发能力:节点可选择 `nftables` 转发模式,面板通过 SSH 在节点机器上下发和维护 nftables 规则。
|
||||
|
||||
第一阶段只支持端口级 DNAT/SNAT 纯转发。它不是 GOST 隧道能力的替代品,也不支持链路、限速、流量统计、连接数限制、Proxy Protocol、best exit 或 agent 诊断。目标是提供一个可靠、可回滚、可重建的轻量转发路径。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 FLVX 的转发模型由三部分组成:
|
||||
|
||||
- `node` 表描述节点,现有本地节点通过 agent WebSocket 接收运行时命令。
|
||||
- `tunnel` 表描述入口、出口和链路类型,`type=1` 表示端口转发,`type=2` 表示隧道转发。
|
||||
- `forward` 表描述用户规则、入口端口和目标地址,运行时通过 GOST service 下发到入口节点。
|
||||
|
||||
nftables 模式的核心差异是没有 agent,因此不能复用现有 WebSocket command 通道,也不能依赖 agent 上报在线状态、流量和诊断结果。面板必须成为唯一控制面,通过 SSH 把数据库中的期望状态同步到远端 nftables。
|
||||
|
||||
## 用户决策
|
||||
|
||||
- 创建或编辑节点时选择转发模式。
|
||||
- 选择 nftables 转发后,不需要安装 agent。
|
||||
- nftables 转发不支持隧道、流量控制等能力,只支持纯转发。
|
||||
- 规则由面板端维护,并通过 SSH 下放到节点。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
新增节点运行时模式:
|
||||
|
||||
| 模式 | 含义 |
|
||||
|------|------|
|
||||
| `agent` | 默认模式,保持现有 GOST agent 行为 |
|
||||
| `nftables` | 面板通过 SSH 管理 nftables 规则 |
|
||||
|
||||
业务层继续复用现有 `tunnel` 和 `forward` 概念,但对 nftables 模式加严格能力边界:
|
||||
|
||||
- nftables 节点只能创建端口转发隧道。
|
||||
- nftables 隧道不能配置出口节点或转发链。
|
||||
- 同一个隧道的入口节点必须全部是同一种运行时模式。
|
||||
- nftables 转发规则创建、更新、删除时,由后端同步 SSH 规则。
|
||||
- 面板提供节点级“测试 SSH”“重建规则”“清理 FLVX 规则”操作。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不支持 `tunnel.type=2` 隧道转发。
|
||||
- 不支持多跳链路、远程面板共享节点和 federation runtime。
|
||||
- 不支持 GOST service 能力:限速、每 IP 限速、最大连接数、Proxy Protocol、策略负载均衡。
|
||||
- 不支持 agent 流量统计、实时系统指标、节点升级、回退、agent 安装命令。
|
||||
- 不在第一阶段支持 HA 漂移、自动探活切换或复杂负载均衡。
|
||||
- 不改写用户机器上的非 FLVX nftables 规则。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### node 表
|
||||
|
||||
新增字段:
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `forward_mode` | string | `agent` | `agent` 或 `nftables` |
|
||||
|
||||
Go 模型使用 SQLite/PostgreSQL 兼容 tag:
|
||||
|
||||
```go
|
||||
ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"`
|
||||
```
|
||||
|
||||
### node_ssh_config 表
|
||||
|
||||
新增表保存 nftables 节点 SSH 配置。SSH 凭据不放进 `node` 主表,避免普通节点列表过度暴露敏感字段。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 主键 |
|
||||
| `node_id` | 关联节点,唯一 |
|
||||
| `host` | SSH 主机,默认可使用 node.server_ip |
|
||||
| `port` | SSH 端口,默认 22 |
|
||||
| `username` | SSH 用户 |
|
||||
| `auth_type` | `password` 或 `private_key` |
|
||||
| `password` | 加密后密码,可为空 |
|
||||
| `private_key` | 加密后私钥,可为空 |
|
||||
| `passphrase` | 加密后私钥口令,可为空 |
|
||||
| `sudo_mode` | `none` / `sudo` |
|
||||
| `created_time` | 创建时间 |
|
||||
| `updated_time` | 更新时间 |
|
||||
|
||||
第一阶段可使用现有配置密钥派生或面板本地密钥做对称加密;如果项目尚无统一密钥管理,应至少避免在列表 API 返回完整凭据。
|
||||
|
||||
### nft_rule_binding 表
|
||||
|
||||
记录面板认为已经应用到节点的规则状态,用于更新、删除、重建和错误展示。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 主键 |
|
||||
| `forward_id` | 转发规则 ID |
|
||||
| `node_id` | 下发节点 ID |
|
||||
| `in_port` | 入口端口 |
|
||||
| `protocols` | 第一阶段固定 `tcp,udp` |
|
||||
| `target_addr` | 目标地址 |
|
||||
| `bind_ip` | 可选监听 IP |
|
||||
| `rule_hash` | 当前期望规则 hash |
|
||||
| `status` | `pending` / `applied` / `error` |
|
||||
| `last_error` | 最近错误 |
|
||||
| `applied_time` | 最近成功应用时间 |
|
||||
| `created_time` | 创建时间 |
|
||||
| `updated_time` | 更新时间 |
|
||||
|
||||
绑定表不是最终事实来源。最终期望状态仍从 `forward`、`forward_port`、`tunnel` 和 `chain_tunnel` 推导,绑定表只记录应用结果。
|
||||
|
||||
## API 行为
|
||||
|
||||
### 节点创建和更新
|
||||
|
||||
`/node/create` 和 `/node/update` 新增入参:
|
||||
|
||||
```json
|
||||
{
|
||||
"forwardMode": "nftables",
|
||||
"sshConfig": {
|
||||
"host": "203.0.113.10",
|
||||
"port": 22,
|
||||
"username": "root",
|
||||
"authType": "private_key",
|
||||
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----...",
|
||||
"passphrase": "",
|
||||
"sudoMode": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `forwardMode` 缺省时按 `agent`。
|
||||
- `agent` 节点保留现有字段和行为。
|
||||
- `nftables` 节点要求 SSH 配置完整。
|
||||
- 从 `agent` 切到 `nftables` 前,若该节点已有 agent 隧道链路或转发规则,应拒绝并提示先迁移或删除。
|
||||
- 从 `nftables` 切回 `agent` 前,若存在 nftables 规则,应拒绝并提示先清理或迁移。
|
||||
|
||||
### 隧道创建和更新
|
||||
|
||||
创建 nftables 隧道仍使用 `/tunnel/create`,但后端根据入口节点模式校验能力。
|
||||
|
||||
规则:
|
||||
|
||||
- 入口节点为 nftables 时,`type` 必须为 `1`。
|
||||
- 不允许提交 `outNodeId` 或 `chainNodes`。
|
||||
- 入口节点必须在线的现有校验不能直接套用到 nftables 节点;应改为 SSH 可用性校验或允许保存后手动测试。
|
||||
- 同一隧道入口节点不能混用 `agent` 和 `nftables`。
|
||||
- 更新隧道时不允许改变运行时模式;需要通过迁移规则到新隧道实现。
|
||||
|
||||
### 转发创建和更新
|
||||
|
||||
选择 nftables 隧道时,`/forward/create` 和 `/forward/update` 强制收窄字段:
|
||||
|
||||
- `speedId` 必须为空。
|
||||
- `ipSpeedId` 必须为空。
|
||||
- `maxConn` 和 `ipMaxConn` 必须为 0。
|
||||
- `proxyProtocol` 必须为 0。
|
||||
- 第一阶段 `remoteAddr` 只允许单目标 `host:port`。
|
||||
- `strategy` 固定为 `fifo` 或忽略。
|
||||
|
||||
创建流程:
|
||||
|
||||
1. 校验权限、隧道状态、端口占用和 nftables 能力边界。
|
||||
2. 在数据库创建 `forward` 和 `forward_port`。
|
||||
3. 通过 nftables runtime 对关联入口节点执行同步。
|
||||
4. 若同步失败,回滚数据库创建,返回 SSH/nftables 错误。
|
||||
|
||||
更新流程:
|
||||
|
||||
1. 保存旧 forward 和端口绑定。
|
||||
2. 更新数据库。
|
||||
3. 同步 nftables 规则。
|
||||
4. 若同步失败,回滚数据库状态并尝试恢复旧规则。
|
||||
|
||||
删除流程:
|
||||
|
||||
1. 先删除远端 nftables 规则。
|
||||
2. 成功后删除数据库。
|
||||
3. 如果远端删除失败,普通删除返回错误;强制删除可删除数据库并保留 binding 错误记录,提示用户稍后清理。
|
||||
|
||||
## 后端组件
|
||||
|
||||
新增 package:
|
||||
|
||||
```text
|
||||
go-backend/internal/runtime/nftables/
|
||||
```
|
||||
|
||||
建议拆分:
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `Manager` | 对 handler 暴露 Apply/Delete/Reconcile/Test 方法 |
|
||||
| `Planner` | 从数据库记录生成节点级期望规则 |
|
||||
| `Renderer` | 把期望规则渲染为 nftables 脚本 |
|
||||
| `SSHRunner` | 负责 SSH 连接、sudo 包装、命令执行和超时 |
|
||||
| `Parser` | 解析目标地址、协议和错误信息 |
|
||||
|
||||
handler 不直接执行 SSH,也不拼 nft 脚本;handler 只做业务校验并调用 runtime manager。
|
||||
|
||||
## nftables 规则设计
|
||||
|
||||
FLVX 只维护自己的 table,避免触碰用户已有规则:
|
||||
|
||||
```nft
|
||||
table inet flvx {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
每条 forward 生成 TCP 和 UDP 规则:
|
||||
|
||||
```nft
|
||||
tcp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 tcp"
|
||||
udp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 udp"
|
||||
```
|
||||
|
||||
第一阶段默认生成 masquerade:
|
||||
|
||||
```nft
|
||||
masquerade comment "flvx masquerade"
|
||||
```
|
||||
|
||||
原因是大多数纯 DNAT 场景需要回程可达;如果不做 SNAT,目标服务回包可能绕过转发节点导致连接失败。后续可增加高级开关允许用户关闭 masquerade。
|
||||
|
||||
### 原子同步策略
|
||||
|
||||
推荐节点级 reconcile,而不是逐条追加:
|
||||
|
||||
1. 从数据库查询该节点所有 nftables forward。
|
||||
2. 生成完整 `table inet flvx` 脚本。
|
||||
3. 通过 SSH 执行 `nft -f <tempfile>`。
|
||||
4. 成功后更新所有相关 `nft_rule_binding` 状态和 hash。
|
||||
|
||||
这样可以避免局部更新导致规则漂移,也能让“重建规则”与创建/更新走同一条路径。
|
||||
|
||||
## SSH 执行策略
|
||||
|
||||
基础要求:
|
||||
|
||||
- 默认超时 10-15 秒。
|
||||
- 支持密码和私钥认证。
|
||||
- 支持 `sudo nft ...`。
|
||||
- 执行前检查 `command -v nft`。
|
||||
- 执行前检查 `nft --version`,错误时提示安装 nftables。
|
||||
- 所有临时脚本写入 `/tmp/flvx-nft-<nonce>.nft`,执行后删除。
|
||||
|
||||
建议命令流程:
|
||||
|
||||
```sh
|
||||
cat > /tmp/flvx-nft-xxxx.nft <<'EOF'
|
||||
table inet flvx {
|
||||
...
|
||||
}
|
||||
EOF
|
||||
nft list table inet flvx >/dev/null 2>&1 && nft delete table inet flvx || true
|
||||
nft -f /tmp/flvx-nft-xxxx.nft
|
||||
rm -f /tmp/flvx-nft-xxxx.nft
|
||||
```
|
||||
|
||||
如果目标 nft 版本支持 `destroy table`,也可以把删除动作放进脚本:
|
||||
|
||||
```nft
|
||||
destroy table inet flvx
|
||||
table inet flvx {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
实现时应按目标 nft 版本兼容性选择 `destroy` 或 shell 中先检测 `nft list table inet flvx`。
|
||||
|
||||
## 前端体验
|
||||
|
||||
### 节点页
|
||||
|
||||
节点表单新增“转发模式”:
|
||||
|
||||
- `Agent 节点`:默认,现有表单不变。
|
||||
- `nftables 节点`:显示 SSH 配置区块,隐藏 agent 安装相关提示。
|
||||
|
||||
nftables 节点列表操作:
|
||||
|
||||
- 测试 SSH
|
||||
- 重建规则
|
||||
- 清理 FLVX nftables 规则
|
||||
|
||||
隐藏或禁用:
|
||||
|
||||
- 安装命令
|
||||
- 升级
|
||||
- 回退
|
||||
- agent 协议开关
|
||||
- 实时 agent 指标入口
|
||||
|
||||
### 隧道页
|
||||
|
||||
隧道类型文案建议改为更明确的运行时说明:
|
||||
|
||||
- `Agent 端口转发`
|
||||
- `Agent 隧道转发`
|
||||
- `nftables 纯转发`
|
||||
|
||||
如果保持现有 `端口转发 / 隧道转发` 选择器,则在选择 nftables 入口节点后禁用隧道转发,并提示“不支持出口节点和转发链”。
|
||||
|
||||
### 转发页
|
||||
|
||||
选择 nftables 隧道后:
|
||||
|
||||
- 隐藏限速、每 IP 限速、最大连接数、Proxy Protocol。
|
||||
- 目标地址输入提示“第一阶段仅支持单目标 host:port”。
|
||||
- 创建/更新失败时显示远端 SSH 或 nftables 错误。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- SSH 连接失败:返回“SSH 连接失败”,保留底层错误摘要。
|
||||
- 认证失败:返回“SSH 认证失败,请检查用户名和凭据”。
|
||||
- `nft` 不存在:返回“节点未安装 nftables”。
|
||||
- nft 脚本失败:返回 nft stderr 摘要,并记录到 `nft_rule_binding.last_error`。
|
||||
- 下发超时:标记 binding 为 `error`,允许用户重试“重建规则”。
|
||||
- 数据库成功但远端失败时,创建/更新路径应回滚数据库;批量重建路径不回滚业务规则,只记录错误。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- SSH 凭据只在创建/更新时接收,列表 API 不返回明文。
|
||||
- 私钥和密码在数据库中加密保存。
|
||||
- 后端日志不得打印完整私钥、密码或 passphrase。
|
||||
- nft 脚本只由后端 renderer 生成,禁止直接拼接用户提交的自由文本。
|
||||
- `remoteAddr` 必须严格解析为 host/IP + port,端口必须为 1-65535。
|
||||
- `inPort` 仍复用现有端口占用校验。
|
||||
- comment 中只放 forward ID 和协议,不放用户输入。
|
||||
|
||||
## 与现有功能的关系
|
||||
|
||||
- `node/install` 对 nftables 节点返回错误或前端隐藏入口。
|
||||
- `node/check-status` 对 nftables 节点可返回 SSH 测试状态,而不是 agent 在线状态。
|
||||
- `forward/batch-redeploy` 对 nftables 规则执行节点级 reconcile。
|
||||
- `tunnel/batch-redeploy` 遇到 nftables 隧道时只重建相关 nftables 节点规则,不发送 GOST chain/service 命令。
|
||||
- federation 导入/共享第一阶段不支持 nftables 节点。
|
||||
- backup/import 应包含新增 node mode、SSH 配置和 binding 状态;导出时默认不导出 SSH 明文凭据。
|
||||
|
||||
## 测试计划
|
||||
|
||||
后端单元测试:
|
||||
|
||||
- nftables 节点不能创建隧道转发。
|
||||
- nftables 隧道不能包含出口节点或转发链。
|
||||
- agent 和 nftables 节点不能混在同一隧道。
|
||||
- nftables forward 拒绝限速、连接限制和 Proxy Protocol。
|
||||
- nftables forward 拒绝多目标 remoteAddr。
|
||||
- renderer 为 TCP/UDP 生成稳定脚本和 comment。
|
||||
- SSH runner 正确隐藏敏感信息并返回 stderr 摘要。
|
||||
|
||||
后端集成测试:
|
||||
|
||||
- 创建 nftables forward 时数据库和 binding 同步成功。
|
||||
- runtime 下发失败时创建回滚。
|
||||
- 更新失败时数据库和旧规则尽量恢复。
|
||||
- 删除失败时普通删除返回错误,强制删除保留清理提示。
|
||||
|
||||
前端验证:
|
||||
|
||||
- 节点表单按转发模式切换字段。
|
||||
- nftables 节点隐藏安装/升级/回退操作。
|
||||
- 隧道表单阻止 nftables 隧道转发配置。
|
||||
- 转发表单选择 nftables 隧道后隐藏不支持字段。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 数据模型和 repository:新增字段、SSH 配置表、binding 表和查询方法。
|
||||
2. nftables runtime:实现 planner、renderer、SSH runner、manager。
|
||||
3. handler 校验:节点、隧道、转发 create/update/delete 接入 runtime。
|
||||
4. 前端节点表单:增加转发模式和 SSH 配置。
|
||||
5. 前端隧道/转发表单:按 nftables 能力收窄 UI。
|
||||
6. 批量重建和清理操作:提供运维入口。
|
||||
7. 测试与文案打磨。
|
||||
|
||||
## 第一阶段固定决策
|
||||
|
||||
本设计先固定以下选择,除非审核时调整:
|
||||
|
||||
- 第一阶段同时下发 TCP 和 UDP。
|
||||
- 第一阶段只支持单目标。
|
||||
- 第一阶段默认启用 masquerade。
|
||||
- nftables 节点的“在线状态”以 SSH 测试为准,而不是常驻连接。
|
||||
@@ -0,0 +1,284 @@
|
||||
# nftables 流量统计设计
|
||||
|
||||
**日期**: 2026-06-06
|
||||
**状态**: 待审核
|
||||
**作者**: Codex
|
||||
|
||||
## 概述
|
||||
|
||||
为 FLVX 的 `nftables` 转发模式补齐流量统计。当前 nftables 模式由面板通过 SSH 全量维护 `table inet flvx`,但没有 agent,因此不能复用 WebSocket 运行时上报。新方案由面板定时通过 SSH 拉取远端 nftables counter,计算增量后写入现有流量账本。
|
||||
|
||||
目标是让 nftables 转发在用户可见口径上尽量接近 agent 模式:
|
||||
|
||||
- forward 列表显示 `inFlow` / `outFlow`。
|
||||
- 用户、用户隧道、配额和流量策略继续生效。
|
||||
- 隧道监控继续获得分钟级 `tunnel_metric`。
|
||||
- 节点不需要安装新的 agent 或常驻进程。
|
||||
|
||||
## 背景
|
||||
|
||||
现有 agent 模式通过 `/flow/upload` 接收加密上报,handler 会把服务名解析为 `forward_id/user_id/user_tunnel_id`,再复用以下路径:
|
||||
|
||||
- `ApplyFlowUploadDeltasBatch` 更新 `forward`、`user`、`user_tunnel`。
|
||||
- `AddUserQuotaUsageBatch` 更新用户配额窗口。
|
||||
- `enforceUserQuotaIfNeeded` 和 `enforceFlowPolicies` 做约束 enforcement。
|
||||
- `recordTunnelMetricsFromForwardBatch` 写入分钟级隧道监控。
|
||||
|
||||
nftables 模式已经有 `nft_rule_binding` 记录规则应用状态,规则 comment 里包含 `forward_id`。这给 counter 到业务实体的映射提供了稳定锚点。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用“面板 SSH 轮询 nftables counter”的方案:
|
||||
|
||||
1. 渲染 nftables 规则时,为每个 forward、协议和方向写入稳定 comment 和 `counter`。
|
||||
2. 后端定时扫描 `forward_mode = nftables` 的节点。
|
||||
3. 对每个节点通过 SSH 执行 `nft -j list table inet flvx`。
|
||||
4. 解析 JSON 规则,按 comment 得到 `forward_id/protocol/direction/bytes/packets`。
|
||||
5. 用数据库中的上次采样值计算 delta。
|
||||
6. 将 delta 转成现有 flow upload 内部结构,复用既有入账、配额、策略和监控逻辑。
|
||||
|
||||
不采用节点 crontab 或 systemd timer 回推。它会重新引入节点侧组件,削弱 nftables 模式“不安装 agent”的产品边界。
|
||||
|
||||
## 统计口径
|
||||
|
||||
正式入账使用 `forward` filter chain 的计数,不使用 NAT chain 的 DNAT 命中计数作为主口径。
|
||||
|
||||
原因:
|
||||
|
||||
- DNAT counter 表示规则命中,不一定代表后续转发成功。
|
||||
- filter forward chain 更接近实际经过内核转发的数据。
|
||||
- SNAT/masquerade 会改变包头,入账规则应在可稳定匹配目标服务地址和端口的位置统计。
|
||||
|
||||
方向定义:
|
||||
|
||||
| direction | nft 匹配 | 写入字段 |
|
||||
|-----------|----------|----------|
|
||||
| `to-target` | 外部客户端到目标服务 | `in_flow` |
|
||||
| `from-target` | 目标服务返回外部客户端 | `out_flow` |
|
||||
|
||||
用户总用量和配额仍按 `in_flow + out_flow` 计算。隧道 `traffic_ratio` 和 `flow` 倍率继续沿用 agent 模式逻辑,保证不同运行时模式的账单口径一致。
|
||||
|
||||
## nftables 规则设计
|
||||
|
||||
继续只维护 `table inet flvx`,避免触碰用户已有规则。每条 forward 对 TCP 和 UDP 各生成一组 DNAT 和统计规则。
|
||||
|
||||
示例:
|
||||
|
||||
```nft
|
||||
table inet flvx {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"
|
||||
udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
masquerade comment "flvx masquerade"
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"
|
||||
ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"
|
||||
ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"
|
||||
ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
IPv6 目标使用 `ip6`:
|
||||
|
||||
```nft
|
||||
ip6 daddr 2001:db8::20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"
|
||||
ip6 saddr 2001:db8::20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"
|
||||
```
|
||||
|
||||
域名目标无法在 nftables 规则中动态匹配返回方向。统计第一阶段要求 nftables forward 的 `remoteAddr` host 必须是 IP 地址;如果当前纯转发实现允许域名,开启统计时应同步收紧校验。后续若要支持域名,应在规则同步时解析并固化 IP,同时明确 DNS 变化后的重建策略。
|
||||
|
||||
## Comment 格式
|
||||
|
||||
正式统计规则使用固定格式:
|
||||
|
||||
```text
|
||||
flvx forward:<forward_id> <direction> <protocol>
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
- `forward_id`: 十进制整数。
|
||||
- `direction`: `to-target` 或 `from-target`。
|
||||
- `protocol`: `tcp` 或 `udp`。
|
||||
|
||||
DNAT 调试规则可使用 `dnat` direction,但 collector 不入账 `dnat`。后端只依赖 comment 解析,不依赖 nft handle,因为全量重建 table 会改变 handle。
|
||||
|
||||
## 数据模型
|
||||
|
||||
新增 `nft_counter_state` 表保存上次采样基线。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 主键 |
|
||||
| `node_id` | nftables 节点 ID |
|
||||
| `forward_id` | 转发规则 ID |
|
||||
| `protocol` | `tcp` / `udp` |
|
||||
| `direction` | `to-target` / `from-target` |
|
||||
| `rule_hash` | 当前规则 hash |
|
||||
| `bytes` | 上次采样绝对字节数 |
|
||||
| `packets` | 上次采样绝对包数 |
|
||||
| `collected_time` | 上次采样时间 |
|
||||
| `created_time` | 创建时间 |
|
||||
| `updated_time` | 更新时间 |
|
||||
|
||||
唯一索引:
|
||||
|
||||
```text
|
||||
node_id, forward_id, protocol, direction
|
||||
```
|
||||
|
||||
GORM 模型必须定义 `TableName()`,字段 tag 保持 SQLite/PostgreSQL 兼容,不使用 `jsonb`、`serial` 等数据库专属类型。
|
||||
|
||||
## 后端组件
|
||||
|
||||
扩展 `go-backend/internal/runtime/nftables`:
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `CounterSample` | 表达单条 nft counter 采样 |
|
||||
| `Collector` | 对外提供 `Collect(ctx, cfg)` |
|
||||
| `SSHRunner.ListTableJSON` | 远端执行 `nft -j list table inet flvx` |
|
||||
| `ParseCounterSamples` | 解析 nft JSON 和 FLVX comment |
|
||||
|
||||
扩展 repository:
|
||||
|
||||
| 方法 | 职责 |
|
||||
|------|------|
|
||||
| `ListNftablesNodesForCollection` | 找到启用 nftables 且有 SSH 配置的节点 |
|
||||
| `GetNftCounterStatesByNode` | 读取节点上次 counter 基线 |
|
||||
| `UpsertNftCounterStates` | 批量刷新基线 |
|
||||
| `DeleteNftCounterStatesByForward` | forward 删除时清理状态 |
|
||||
|
||||
扩展 handler/job:
|
||||
|
||||
- 新增 `runNftablesTrafficCollectJob(now time.Time)`。
|
||||
- 默认每 60 秒运行一次。
|
||||
- 对节点采集设置并发上限,建议 3 到 5。
|
||||
- 单节点失败只记录日志和节点采集状态,不影响其他节点。
|
||||
|
||||
## 增量算法
|
||||
|
||||
collector 返回的是 nftables 的绝对 counter。入账前必须和上次基线做差。
|
||||
|
||||
规则:
|
||||
|
||||
- 无旧状态:只保存当前值作为基线,不入账。
|
||||
- `rule_hash` 变化:只刷新基线,不入账,避免新旧规则混算。
|
||||
- 新 bytes 大于等于旧 bytes:`delta = new - old`。
|
||||
- 新 bytes 小于旧 bytes:认为远端 table 重建、counter reset 或系统重启,只刷新基线,不入账。
|
||||
- delta 为 0:刷新采集时间,不入账。
|
||||
- 样本无法映射到有效 forward:忽略并记录 debug 日志。
|
||||
|
||||
同一 forward 的 TCP/UDP delta 要先聚合,再转换成现有账本:
|
||||
|
||||
- `to-target` bytes 聚合为原始 `bytesIn`。
|
||||
- `from-target` bytes 聚合为原始 `bytesOut`。
|
||||
- 入账时按 `traffic_ratio` 和 `tunnel.flow` 计算 scaled `InFlow` / `OutFlow`。
|
||||
- 配额使用 scaled 后的 `InFlow + OutFlow`。
|
||||
- `tunnel_metric` 使用原始 `bytesIn` / `bytesOut`。
|
||||
|
||||
## 入账路径
|
||||
|
||||
新增一个 nftables 专用的 batch builder,但输出沿用现有结构:
|
||||
|
||||
```go
|
||||
type nftTrafficDelta struct {
|
||||
ForwardID int64
|
||||
BytesIn int64
|
||||
BytesOut int64
|
||||
}
|
||||
```
|
||||
|
||||
处理流程:
|
||||
|
||||
1. 收集本轮所有 `forward_id`。
|
||||
2. 调用 `GetFlowUploadForwardMetas` 获取 `user_id/user_tunnel_id/tunnel_id/traffic_ratio/tunnel_flow`。
|
||||
3. 构造 `repo.FlowUploadCounterDelta`。
|
||||
4. 调用 `recordTunnelMetricsFromForwardBatch` 写监控。
|
||||
5. 抽出共享入账 helper,复用 `applyFlowDeltasWithFallback`、`applyQuotaUsageWithFallback`、`enforceUserQuotaIfNeeded` 和 `enforceFlowPolicies`。不要通过伪造 agent service name 去调用 agent 专用 builder。
|
||||
|
||||
不新增独立的 nftables 流量字段。`forward.in_flow/out_flow`、`user.in_flow/out_flow`、`user_tunnel.in_flow/out_flow` 仍是统一事实来源。
|
||||
|
||||
## 错误处理
|
||||
|
||||
采集错误分为三类:
|
||||
|
||||
| 类型 | 行为 |
|
||||
|------|------|
|
||||
| SSH 连接或认证失败 | 记录日志,保留下次继续采集 |
|
||||
| 远端无 `table inet flvx` | 视为规则未应用或被清理,记录 warning,不清空账本 |
|
||||
| JSON 解析失败 | 记录原始错误摘要,不入账 |
|
||||
|
||||
不要因为采集失败禁用 forward。流量统计失败和转发运行失败不是同一件事。
|
||||
|
||||
可在后续 UI 增加节点级采集状态,例如最近成功时间、最近错误。但第一步只要求后端具备日志和数据库状态即可。
|
||||
|
||||
## 与现有行为的关系
|
||||
|
||||
- agent 模式 `/flow/upload` 不变。
|
||||
- nftables 模式不新增节点侧 HTTP 回调。
|
||||
- 现有 `nft_rule_binding.rule_hash` 继续表示规则期望状态;counter state 用它判断采样是否跨规则版本。
|
||||
- `statistics_flow` 小时统计 job 不需要改,它基于用户总流量快照自然包含 nftables 入账结果。
|
||||
- 用户重置流量时不需要清空 nftables counter。重置只清业务账本;下一轮采集继续从 counter state 差值入账。
|
||||
|
||||
## 测试计划
|
||||
|
||||
后端单元测试:
|
||||
|
||||
- renderer 为 TCP/UDP、IPv4/IPv6 目标生成 `counter` 和稳定 comment。
|
||||
- comment parser 能识别合法格式,拒绝未知 direction/protocol。
|
||||
- nft JSON parser 能从 `nft -j list table` 输出中提取 bytes/packets。
|
||||
- delta 算法覆盖首次基线、正常增长、counter reset、rule_hash 变化和零增量。
|
||||
- batch builder 正确应用 `traffic_ratio` 和 `tunnel.flow`。
|
||||
|
||||
repository 测试:
|
||||
|
||||
- `nft_counter_state` 自动迁移。
|
||||
- upsert 在 SQLite 下可重复刷新。
|
||||
- forward 删除时清理 counter state。
|
||||
|
||||
handler/job 测试:
|
||||
|
||||
- 单节点采集成功会调用现有流量入账路径。
|
||||
- 单节点 SSH 失败不影响其他节点。
|
||||
- 无旧状态时不会误把历史 counter 入账。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
## 分阶段落地
|
||||
|
||||
第一阶段:
|
||||
|
||||
- 规则渲染加入 filter chain counter。
|
||||
- 实现 SSH collector、JSON parser、counter state 和后台 job。
|
||||
- 入账到现有账本和 tunnel metric。
|
||||
|
||||
第二阶段:
|
||||
|
||||
- UI 展示 nftables 采集状态。
|
||||
- 节点详情显示最近采集时间和最近错误。
|
||||
- 提供手动“采集一次”诊断按钮。
|
||||
|
||||
第三阶段:
|
||||
|
||||
- 探索域名目标的解析和重建策略。
|
||||
- 优化大量节点下的采集调度、退避和超时配置。
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 采集周期默认 60 秒是否满足产品预期;如果需要更实时,可以降到 30 秒,但 SSH 压力会增加。
|
||||
- nftables 模式是否继续允许域名 remoteAddr。如果允许,需要先定义 DNS 固化和统计匹配规则。
|
||||
- 是否要在第一阶段暴露采集状态 API。推荐后端先记录,UI 后续补齐。
|
||||
@@ -0,0 +1,197 @@
|
||||
# 规则流量清零设计
|
||||
|
||||
## 背景
|
||||
|
||||
Issue #523 希望“规则”页面中每条隧道规则显示的流量使用量支持手动清零。
|
||||
|
||||
当前规则流量保存在 `forward.in_flow` 和 `forward.out_flow`。流量上报时,同一份增量还会累计到用户总流量、用户隧道流量和相关配额统计中。因此,本功能必须将“规则展示计数器清零”与“用户或隧道配额重置”严格区分。
|
||||
|
||||
## 目标
|
||||
|
||||
为单条规则提供手动流量清零能力:
|
||||
|
||||
- 将所选规则的上传流量和下载流量清零。
|
||||
- 管理员可以清零任意规则。
|
||||
- 普通用户只能清零自己的规则。
|
||||
- 清零后,新产生的流量继续从零正常累计。
|
||||
|
||||
## 非目标
|
||||
|
||||
本功能不会:
|
||||
|
||||
- 修改用户总流量 `user.in_flow` 或 `user.out_flow`。
|
||||
- 修改用户隧道流量 `user_tunnel.in_flow` 或 `user_tunnel.out_flow`。
|
||||
- 修改每日或每月配额用量。
|
||||
- 修改历史流量统计。
|
||||
- 重置 nftables 节点计数器或其增量计算基线。
|
||||
- 重启、暂停、恢复或重新部署规则服务。
|
||||
- 增加批量流量清零功能。
|
||||
|
||||
## 后端设计
|
||||
|
||||
### API
|
||||
|
||||
新增接口:
|
||||
|
||||
```text
|
||||
POST /api/v1/forward/reset-flow
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123
|
||||
}
|
||||
```
|
||||
|
||||
成功响应沿用统一 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": null,
|
||||
"ts": 0
|
||||
}
|
||||
```
|
||||
|
||||
具体 `msg`、`data` 和 `ts` 值继续由现有 response helper 生成。
|
||||
|
||||
### 参数与权限校验
|
||||
|
||||
Handler 执行以下步骤:
|
||||
|
||||
1. 只接受 `POST` 请求。
|
||||
2. 从 JSON 请求体读取正整数规则 ID。
|
||||
3. 调用现有 `resolveForwardAccess`:
|
||||
- 管理员角色可以访问任意存在的规则。
|
||||
- 普通用户仅能访问 `forward.user_id` 等于当前用户 ID 的规则。
|
||||
- 对普通用户访问他人规则的情况,沿用现有逻辑返回“转发不存在”,避免暴露规则存在性。
|
||||
4. 调用 Repository 完成清零。
|
||||
5. 返回统一成功响应。
|
||||
|
||||
### Repository
|
||||
|
||||
新增方法:
|
||||
|
||||
```go
|
||||
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error
|
||||
```
|
||||
|
||||
该方法只更新指定 `forward` 记录:
|
||||
|
||||
```text
|
||||
in_flow = 0
|
||||
out_flow = 0
|
||||
updated_time = now
|
||||
```
|
||||
|
||||
Repository 不直接操作 Handler 的身份信息,也不更新任何其他表。
|
||||
|
||||
### 并发与后续流量
|
||||
|
||||
清零使用单条 SQL `UPDATE`。agent 流量上报和 nftables 流量采集仍使用原有增量累加逻辑。清零不会重置采集基线,因此下一次采集只会把清零之后新计算出的增量加回规则计数,不会把清零前的累计值整体恢复。
|
||||
|
||||
若清零 SQL 与流量增量 SQL 同时执行,数据库按实际语句执行顺序决定最终值;每条更新本身保持原子性。本功能不引入暂停采集或跨节点同步流程。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### API 封装
|
||||
|
||||
在 `vite-frontend/src/api/index.ts` 新增:
|
||||
|
||||
```ts
|
||||
export const resetForwardFlow = (id: number) =>
|
||||
Network.post("/forward/reset-flow", { id });
|
||||
```
|
||||
|
||||
### 入口
|
||||
|
||||
在规则页面所有单条规则操作入口中增加“流量清零”操作:
|
||||
|
||||
- 分组表格视图。
|
||||
- 精简表格视图。
|
||||
- 卡片视图。
|
||||
|
||||
按钮使用独立的清零/刷新语义图标和提示文本,不复用删除按钮样式。
|
||||
|
||||
当规则的 `inFlow + outFlow` 等于零时,按钮禁用,避免重复请求。
|
||||
|
||||
### 确认交互
|
||||
|
||||
点击按钮后打开确认弹窗,显示规则名称,并明确说明:
|
||||
|
||||
- 仅清零当前规则显示的上传和下载流量。
|
||||
- 不影响用户总流量、用户隧道配额和历史统计。
|
||||
- 操作不可撤销。
|
||||
|
||||
确认期间显示 loading 状态并阻止重复提交。
|
||||
|
||||
### 成功与失败
|
||||
|
||||
- 成功:关闭弹窗,显示成功 toast,并刷新规则列表。
|
||||
- 失败:保留弹窗,显示后端错误信息或通用失败 toast。
|
||||
- 刷新后,该规则上传和下载均显示为零;后续流量继续正常累计。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 非 POST 请求:返回现有通用请求失败响应。
|
||||
- 请求体无法解析、ID 缺失或 ID 非正数:返回“请求参数错误”。
|
||||
- 规则不存在或普通用户访问他人规则:返回“转发不存在”。
|
||||
- Repository 更新失败:返回包含 Repository 错误信息的统一错误响应。
|
||||
- 前端网络错误:显示“流量清零失败”。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Repository 测试
|
||||
|
||||
验证:
|
||||
|
||||
- 指定规则的 `in_flow`、`out_flow` 被清零。
|
||||
- 指定规则的 `updated_time` 被更新。
|
||||
- 其他规则的流量不变。
|
||||
- 用户总流量不变。
|
||||
- 用户隧道流量不变。
|
||||
- Repository 未初始化时返回错误。
|
||||
|
||||
### Handler 测试
|
||||
|
||||
验证:
|
||||
|
||||
- 管理员能够清零任意存在的规则。
|
||||
- 普通用户能够清零自己的规则。
|
||||
- 普通用户不能清零他人的规则。
|
||||
- 不存在的规则返回错误。
|
||||
- 无效 ID 返回参数错误。
|
||||
- 非 POST 请求返回请求失败。
|
||||
- 成功请求不修改用户和用户隧道流量。
|
||||
|
||||
### 前端验证
|
||||
|
||||
项目没有配置前端测试框架,因此不新增前端单元测试。使用以下命令验证:
|
||||
|
||||
```bash
|
||||
(cd vite-frontend && pnpm run build)
|
||||
(cd vite-frontend && pnpm run lint)
|
||||
```
|
||||
|
||||
后端使用:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
```
|
||||
|
||||
## 文件范围
|
||||
|
||||
预计修改:
|
||||
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `go-backend/internal/http/handler/*_test.go`
|
||||
- `go-backend/internal/store/repo/repository_mutations.go`
|
||||
- `go-backend/internal/store/repo/*_test.go`
|
||||
- `vite-frontend/src/api/index.ts`
|
||||
- `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
不需要数据库迁移或新增依赖。
|
||||
@@ -0,0 +1,133 @@
|
||||
# Preserve UDP reply source addresses on Linux
|
||||
|
||||
The Linux UDP listener preserves the destination address of each incoming
|
||||
datagram so that replies use the correct local source. This change was developed
|
||||
against FLVX `3.0.1-rc4`, commit
|
||||
`d9303032cad09b33cd0b7c91704fd671fbfdd078`.
|
||||
It also includes the `3.0.1-rc5` runtime update at
|
||||
`c794586cc7773e5f967783895d3071538442bbf7`.
|
||||
|
||||
## Problem
|
||||
|
||||
A wildcard UDP socket bound to `0.0.0.0:port` or `[::]:port` can receive datagrams
|
||||
addressed to several local IP addresses. Previously, the listener retained only
|
||||
the client address. A reply sent through `WriteTo` could therefore use a source
|
||||
IP chosen by the kernel instead of the IP the client contacted. DNS clients, for
|
||||
example, can reject such replies as coming from an unexpected source.
|
||||
|
||||
The UDP session pool was also keyed only by the client address and port. When
|
||||
one client socket contacted multiple local addresses on the same listener port,
|
||||
these requests could share a session despite having different destinations.
|
||||
|
||||
## Changes
|
||||
|
||||
- Linux wildcard UDP sockets enable IPv4/IPv6 packet information. `recvmsg`
|
||||
preserves the datagram's local destination and interface; `sendmsg` uses the
|
||||
corresponding source address and interface when replying.
|
||||
- IPv4 keeps the original destination (`ipi_addr`) separate from the local
|
||||
reply address (`ipi_spec_dst`), so broadcast requests receive unicast replies
|
||||
rather than attempting to send with a broadcast source address.
|
||||
- IPv6 multicast destinations remain part of session identity, but replies
|
||||
leave the source unspecified and retain the received interface index so the
|
||||
kernel can select a valid local unicast source.
|
||||
- The destination metadata travels through existing metrics, statistics,
|
||||
admission and traffic-limiter wrappers. The exposed remote address remains
|
||||
the real client address.
|
||||
- Sessions are keyed by the local destination endpoint, remote client endpoint
|
||||
and received interface index. Identical IPv4 broadcast or multicast endpoints
|
||||
on different interfaces therefore retain separate reply control information.
|
||||
An accepted session reports its actual local destination.
|
||||
Link-local IPv6 unicast and link-local or interface-local multicast
|
||||
destinations retain their interface zone, keeping identical multicast groups
|
||||
on different interfaces separate even when the client uses the same
|
||||
non-link-local IP and source port.
|
||||
IPv4 destinations, including link-local unicast and multicast, never receive
|
||||
an IPv6 zone; their interface isolation uses the separate index in the key.
|
||||
- Explicitly bound UDP sockets retain their existing behavior. Non-Linux
|
||||
platforms retain their existing implementation. No configuration keys or
|
||||
panel API changes are required.
|
||||
|
||||
The fix affects the shared UDP listener, including services dynamically created
|
||||
after startup. It does not configure listening addresses, NAT, routing or
|
||||
firewalls. Multi-homed hosts still need correct source policy routing for their
|
||||
uplinks; this patch does not fix SSH/TCP routing or packet loss before ingress.
|
||||
|
||||
## Build
|
||||
|
||||
Use Go 1.26.10 or a compatible newer toolchain. From the repository root:
|
||||
|
||||
```sh
|
||||
cd go-gost
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||
-ldflags '-s -w' -o flux_agent .
|
||||
```
|
||||
|
||||
Use the agent with the normal panel integration and forwarding configuration.
|
||||
Selecting a wildcard listener enables destination preservation automatically
|
||||
on Linux. The patch does not select a listener address or change the upstream
|
||||
connection's outbound interface.
|
||||
|
||||
## Regression tests
|
||||
|
||||
The Linux destination tests cover IPv4 wildcard and dual-stack wildcard sockets,
|
||||
explicit IPv4/IPv6 bindings, one client source port contacting multiple local
|
||||
destinations, repeated requests, session expiry, and active metrics, statistics,
|
||||
admission and traffic-limit wrappers. A separate IPv6 case binds the client to
|
||||
`::1` and checks the exact remote IP and port for every destination. Broadcast
|
||||
tests exercise both IPv4 and dual-stack listeners, retaining the broadcast
|
||||
destination while checking that replies use a local unicast address.
|
||||
An IPv6 multicast regression sends across an isolated veth pair and checks the
|
||||
multicast session destination and the unicast reply, including interface zones.
|
||||
A two-interface case uses one fixed ULA client endpoint to contact the same
|
||||
link-local or interface-local multicast group on both interfaces and checks
|
||||
distinct sessions and their individual reply-interface metadata.
|
||||
IPv4 and dual-stack cases likewise use a single fixed client endpoint to send
|
||||
to one multicast group or the limited broadcast address on two interfaces,
|
||||
checking separate sessions and their reply-interface indices.
|
||||
Link-local IPv4 unicast and multicast regressions also check that accepted
|
||||
local endpoints have no IPv6 zone, on both IPv4 and dual-stack listeners.
|
||||
|
||||
Run the standard suites on Linux:
|
||||
|
||||
```sh
|
||||
(cd go-gost && go test ./...)
|
||||
(cd go-gost/x && go test ./...)
|
||||
```
|
||||
|
||||
To exercise multiple IPv6 addresses without modifying the host's live network,
|
||||
compile the tests first and run them inside a disposable network namespace.
|
||||
The following commands require Linux, root access for `unshare`, and `iproute2`:
|
||||
|
||||
```sh
|
||||
cd go-gost/x
|
||||
go test -c -race -o /tmp/flvx-udp-destination.test ./listener/udp
|
||||
go test -c -race -o /tmp/flvx-udp-packet.test ./internal/net/udp
|
||||
sudo unshare --net -- sh -eu -c '
|
||||
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
|
||||
ip link set lo up
|
||||
ip addr add 198.18.0.1/32 dev lo
|
||||
ip addr add 169.254.1.2/32 dev lo
|
||||
ip -6 addr add fd00:1::1/128 dev lo nodad
|
||||
ip -6 addr add fd00:2::1/128 dev lo nodad
|
||||
ip -6 addr add fd00:3::1/128 dev lo nodad
|
||||
for suffix in "" 2; do
|
||||
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
|
||||
ip link set "mc-tx$suffix" addrgenmode none up
|
||||
ip link set "mc-rx$suffix" addrgenmode none up
|
||||
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
|
||||
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
|
||||
done
|
||||
ip addr add 198.18.1.1/24 dev mc-tx
|
||||
ip addr add 198.18.2.1/24 dev mc-tx2
|
||||
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
|
||||
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
|
||||
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
|
||||
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
|
||||
/tmp/flvx-udp-destination.test -test.v -test.timeout=60s -test.count=5
|
||||
exec /tmp/flvx-udp-packet.test -test.v -test.timeout=60s -test.count=5
|
||||
'
|
||||
```
|
||||
|
||||
The addresses above are isolated test addresses, not deployment settings.
|
||||
The `UDP Destination Regression` GitHub Actions workflow runs these namespace
|
||||
tests, the race detector, both agent module suites, and a Linux agent build.
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
# CVE-2026-111943: crypto/x509 is linked into paneld; rebuild with the fixed Go release.
|
||||
FROM golang:1.26.10-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
@@ -7,7 +8,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
||||
ARG KEYGEN_ACCOUNT_ID
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -ldflags="-X 'go-backend/internal/license.AccountID=${KEYGEN_ACCOUNT_ID}'" -o /out/paneld ./cmd/paneld
|
||||
|
||||
FROM docker:27-cli AS dockercli
|
||||
|
||||
|
||||
+9
-2
@@ -1,13 +1,15 @@
|
||||
module go-backend
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -15,14 +17,19 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
|
||||
+20
-2
@@ -3,10 +3,20 @@ 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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -27,6 +37,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -38,8 +50,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
@@ -49,6 +65,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
|
||||
@@ -29,6 +29,10 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if value, gated := h.unlicensedPublicBrandValue(configName); gated {
|
||||
response.WriteJSON(w, response.OK(map[string]string{"name": configName, "value": value}))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
@@ -42,3 +46,22 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
response.WriteJSON(w, response.OK(cfg))
|
||||
}
|
||||
|
||||
func (h *Handler) unlicensedPublicBrandValue(configName string) (string, bool) {
|
||||
switch configName {
|
||||
case "app_name", "app_logo", "app_favicon", "hide_footer_brand":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if isCommercial == "true" {
|
||||
return "", false
|
||||
}
|
||||
if configName == "app_name" {
|
||||
return "FLVX", true
|
||||
}
|
||||
if configName == "hide_footer_brand" {
|
||||
return "false", true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
||||
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||
seedConfigValue(t, r, "app_bg_image", "bg-data")
|
||||
seedConfigValue(t, r, "app_bg_image_light", "light-bg-data")
|
||||
seedConfigValue(t, r, "app_bg_image_dark", "dark-bg-data")
|
||||
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
@@ -28,6 +30,50 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
for name, want := range map[string]string{
|
||||
"app_bg_image_light": "light-bg-data",
|
||||
"app_bg_image_dark": "dark-bg-data",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerConfigValue(t, resp, name, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||
seedConfigValue(t, r, "hide_footer_brand", "true")
|
||||
seedConfigValue(t, r, "is_commercial", "false")
|
||||
|
||||
for name, want := range map[string]string{
|
||||
"app_name": "FLVX",
|
||||
"app_logo": "",
|
||||
"app_favicon": "",
|
||||
"hide_footer_brand": "false",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerConfigValue(t, resp, name, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBrandConfigUsesSavedValuesWithCommercialLicense(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||
seedConfigValue(t, r, "is_commercial", "true")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerConfigValue(t, resp, "app_name", "Paid Brand")
|
||||
}
|
||||
|
||||
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
||||
@@ -82,6 +128,56 @@ func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
|
||||
}
|
||||
|
||||
func TestConfigGetNeverReturnsLicenseCredentials(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
seedConfigValue(t, r, "license_key", "license-secret")
|
||||
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||
|
||||
for _, name := range []string{"license_key", "license_machine_id", "machine_fingerprint"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问系统授权凭据")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListNeverReturnsLicenseCredentials(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
seedConfigValue(t, r, "license_key", "license-secret")
|
||||
seedConfigValue(t, r, "license_machine_id", "machine-id")
|
||||
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||
seedConfigValue(t, r, "is_commercial", "true")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 || out.Data["is_commercial"] != "true" {
|
||||
t.Fatalf("unexpected config response: %+v", out)
|
||||
}
|
||||
if _, ok := out.Data["license_key"]; ok {
|
||||
t.Fatal("license_key must not be returned")
|
||||
}
|
||||
if _, ok := out.Data["license_machine_id"]; ok {
|
||||
t.Fatal("license_machine_id must not be returned")
|
||||
}
|
||||
if _, ok := out.Data["machine_fingerprint"]; ok {
|
||||
t.Fatal("machine_fingerprint must not be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
@@ -154,7 +250,7 @@ func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
||||
func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
@@ -165,18 +261,13 @@ func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
||||
func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
@@ -187,14 +278,9 @@ func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
@@ -58,6 +59,7 @@ type diagnosisWorkItem struct {
|
||||
type diagnosisExecOptions struct {
|
||||
commandTimeout time.Duration
|
||||
pingTimeoutMS int
|
||||
pingCount int
|
||||
timeoutMessage string
|
||||
}
|
||||
|
||||
@@ -240,6 +242,19 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nftMode, entryNodeIDs, err := h.tunnelUsesNftables(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nftMode {
|
||||
if err := h.validateNftablesForwardRequest(tunnel, forward.RemoteAddr, entryNodeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entryNodeIDs) == 0 {
|
||||
return nil, errors.New("nftables 转发缺少入口节点")
|
||||
}
|
||||
return nil, h.syncNftablesNode(entryNodeIDs[0])
|
||||
}
|
||||
ports, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -519,7 +534,22 @@ func buildForwardServiceDeleteNames(bases []string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
type forwardServiceControlOptions struct {
|
||||
tolerateNotFound bool
|
||||
tolerateOffline bool
|
||||
allowSelfHeal bool
|
||||
continueOnError bool
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
return h.controlForwardServicesWithOptions(forward, commandType, forwardServiceControlOptions{
|
||||
tolerateNotFound: tolerateNotFound,
|
||||
tolerateOffline: true,
|
||||
allowSelfHeal: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, commandType string, opts forwardServiceControlOptions) error {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward control context")
|
||||
}
|
||||
@@ -546,6 +576,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
var nodeErrors []error
|
||||
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
seen := map[int64]struct{}{}
|
||||
for _, fp := range ports {
|
||||
@@ -554,16 +585,20 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
}
|
||||
seen[fp.NodeID] = struct{}{}
|
||||
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
if tolerateNotFound && isNotFoundError(err) {
|
||||
if opts.tolerateNotFound && isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
if opts.continueOnError {
|
||||
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return errors.Join(nodeErrors...)
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
healed := false
|
||||
@@ -573,37 +608,50 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
}
|
||||
seen[fp.NodeID] = struct{}{}
|
||||
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
|
||||
return healErr
|
||||
}
|
||||
healed = true
|
||||
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
nodeErr := func() error {
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if nodeHandled {
|
||||
continue
|
||||
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
|
||||
return healErr
|
||||
}
|
||||
healed = true
|
||||
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if nodeHandled || opts.tolerateNotFound {
|
||||
return nil
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
return lastNotFoundErr
|
||||
}
|
||||
return errors.New("service control failed")
|
||||
}()
|
||||
if nodeErr != nil {
|
||||
if !opts.continueOnError {
|
||||
return nodeErr
|
||||
}
|
||||
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, nodeErr))
|
||||
}
|
||||
if tolerateNotFound {
|
||||
continue
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
return lastNotFoundErr
|
||||
}
|
||||
return errors.New("service control failed")
|
||||
}
|
||||
return nil
|
||||
return errors.Join(nodeErrors...)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeCommandError(nodeID int64, err error) error {
|
||||
name := strconv.FormatInt(nodeID, 10)
|
||||
if node, lookupErr := h.getNodeRecord(nodeID); lookupErr == nil && node != nil {
|
||||
name = defaultString(strings.TrimSpace(node.Name), name)
|
||||
}
|
||||
return fmt.Errorf("节点 %s (ID %d): %w", name, nodeID, err)
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
|
||||
@@ -759,6 +807,9 @@ func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRe
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if payload, handled, err := h.diagnoseNftablesForwardRuntime(forward); handled || err != nil {
|
||||
return payload, err
|
||||
}
|
||||
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -774,6 +825,111 @@ func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRe
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (h *Handler) diagnoseNftablesForwardRuntime(forward *forwardRecord) (map[string]interface{}, bool, error) {
|
||||
if forward == nil {
|
||||
return nil, false, errForwardNotFound
|
||||
}
|
||||
nftMode, entryNodeIDs, err := h.tunnelUsesNftables(forward.TunnelID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !nftMode {
|
||||
return nil, false, nil
|
||||
}
|
||||
if len(entryNodeIDs) == 0 {
|
||||
return nil, true, errors.New("nftables 转发缺少入口节点")
|
||||
}
|
||||
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
results, err := h.buildNftablesForwardDiagnosisResults(forward, entryNodeIDs[0], targets)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"forwardName": forward.Name,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"results": results,
|
||||
}
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildNftablesForwardDiagnosisResults(forward *forwardRecord, nodeID int64, targets []diagnosisTarget) ([]map[string]interface{}, error) {
|
||||
if h == nil || h.repo == nil {
|
||||
return nil, errors.New("handler not initialized")
|
||||
}
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings, err := h.repo.ListNftRuleBindingsByNode(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var binding *model.NftRuleBinding
|
||||
for i := range bindings {
|
||||
if bindings[i].ForwardID == forward.ID {
|
||||
binding = &bindings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
target := diagnosisTarget{}
|
||||
if len(targets) > 0 {
|
||||
target = targets[0]
|
||||
}
|
||||
|
||||
status := "missing"
|
||||
message := "nftables 规则未下发"
|
||||
success := false
|
||||
inPort := 0
|
||||
protocols := ""
|
||||
targetAddr := strings.TrimSpace(forward.RemoteAddr)
|
||||
ruleHash := ""
|
||||
if binding != nil {
|
||||
status = strings.ToLower(strings.TrimSpace(binding.Status))
|
||||
inPort = binding.InPort
|
||||
protocols = strings.TrimSpace(binding.Protocols)
|
||||
targetAddr = strings.TrimSpace(binding.TargetAddr)
|
||||
ruleHash = strings.TrimSpace(binding.RuleHash)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
if status == runtimenft.StatusApplied {
|
||||
success = true
|
||||
message = "nftables 规则已下发"
|
||||
} else if strings.TrimSpace(binding.LastError) != "" {
|
||||
message = binding.LastError
|
||||
} else {
|
||||
message = "nftables 规则未完成下发"
|
||||
}
|
||||
}
|
||||
|
||||
packetLoss := 100
|
||||
if success {
|
||||
packetLoss = 0
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"success": success,
|
||||
"nodeName": node.Name,
|
||||
"nodeId": strconv.FormatInt(nodeID, 10),
|
||||
"targetIp": target.IP,
|
||||
"targetPort": target.Port,
|
||||
"description": fmt.Sprintf("nftables规则(%s)->目标(%s)", node.Name, defaultString(target.Address, targetAddr)),
|
||||
"averageTime": 0,
|
||||
"packetLoss": packetLoss,
|
||||
"message": message,
|
||||
"fromChainType": 1,
|
||||
"forwardMode": "nftables",
|
||||
"nftRuleStatus": status,
|
||||
"nftRuleHash": ruleHash,
|
||||
"inPort": inPort,
|
||||
"protocols": protocols,
|
||||
"targetAddr": targetAddr,
|
||||
}
|
||||
return []map[string]interface{}{result}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {
|
||||
if forward == nil {
|
||||
return "", nil, errForwardNotFound
|
||||
@@ -1474,10 +1630,14 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diag
|
||||
if options.pingTimeoutMS <= 0 {
|
||||
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
pingCount := options.pingCount
|
||||
if pingCount <= 0 {
|
||||
pingCount = 4
|
||||
}
|
||||
res, err := h.sendNodeCommandWithTimeout(nodeID, "TcpPing", map[string]interface{}{
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"count": 4,
|
||||
"count": pingCount,
|
||||
"timeout": options.pingTimeoutMS,
|
||||
}, options.commandTimeout, false, false)
|
||||
if err != nil {
|
||||
@@ -1504,12 +1664,16 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, op
|
||||
if options.pingTimeoutMS <= 0 {
|
||||
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
pingCount := options.pingCount
|
||||
if pingCount <= 0 {
|
||||
pingCount = 4
|
||||
}
|
||||
|
||||
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
|
||||
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
Count: 4,
|
||||
Count: pingCount,
|
||||
Timeout: options.pingTimeoutMS,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
@@ -1744,6 +1908,7 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
targets := splitRemoteTargets(forward.RemoteAddr)
|
||||
strategy := strings.TrimSpace(forward.Strategy)
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(forward.ProxyProtocol, forward.ProxyProtocolReceive, forward.ProxyProtocolSend)
|
||||
if strategy == "" {
|
||||
strategy = "fifo"
|
||||
}
|
||||
@@ -1788,12 +1953,16 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
if runtimeLimiters.TrafficLimiter != "" {
|
||||
service["limiter"] = runtimeLimiters.TrafficLimiter
|
||||
}
|
||||
if forward.ProxyProtocol > 0 {
|
||||
if proxyProtocolReceive > 0 {
|
||||
serviceMetadata := ensureServiceMetadata(service)
|
||||
serviceMetadata["proxyProtocol"] = proxyProtocolReceive
|
||||
}
|
||||
if proxyProtocolSend > 0 {
|
||||
handlerConfig := service["handler"].(map[string]interface{})
|
||||
if handlerConfig["metadata"] == nil {
|
||||
handlerConfig["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
handlerConfig["metadata"].(map[string]interface{})["proxyProtocol"] = forward.ProxyProtocol
|
||||
handlerConfig["metadata"].(map[string]interface{})["proxyProtocol"] = proxyProtocolSend
|
||||
}
|
||||
if protocol == "udp" {
|
||||
listenerMetadata := map[string]interface{}{
|
||||
@@ -1806,10 +1975,8 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||
}
|
||||
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
if service["metadata"] == nil {
|
||||
service["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
service["metadata"].(map[string]interface{})["interface"] = node.InterfaceName
|
||||
serviceMetadata := ensureServiceMetadata(service)
|
||||
serviceMetadata["interface"] = node.InterfaceName
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
@@ -1828,6 +1995,25 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
|
||||
return nodes
|
||||
}
|
||||
|
||||
func ensureServiceMetadata(service map[string]interface{}) map[string]interface{} {
|
||||
if service["metadata"] == nil {
|
||||
service["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
metadata, ok := service["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
metadata = map[string]interface{}{}
|
||||
service["metadata"] = metadata
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func normalizeForwardProxyProtocol(legacy, receive, send int) (int, int) {
|
||||
if send == 0 && legacy > 0 {
|
||||
send = legacy
|
||||
}
|
||||
return receive, send
|
||||
}
|
||||
|
||||
func processServerAddress(serverAddr string) string {
|
||||
serverAddr = normalizeServerAddressInput(serverAddr)
|
||||
if serverAddr == "" {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -408,9 +411,26 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
share, _ := h.repo.GetPeerShare(req.ID)
|
||||
share, err := h.repo.GetPeerShare(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if share != nil {
|
||||
// Revoke new allocations before cleanup; failed deletions keep the
|
||||
// disabled share and its reservations available for retry.
|
||||
share.IsActive = 0
|
||||
share.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShare(share); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.cleanupPeerShareRuntimes(req.ID)
|
||||
if err := h.cleanupPeerShareRuntimes(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
h.cleanupFederationTunnels(req.ID)
|
||||
|
||||
if err := h.repo.DeletePeerShare(req.ID); err != nil {
|
||||
@@ -418,10 +438,6 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if share != nil && h.wsServer != nil {
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -805,16 +821,6 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if share.IsActive == 0 {
|
||||
response.WriteJSON(w, response.Err(403, "Share is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
if share.ExpiryTime > 0 && share.ExpiryTime < time.Now().UnixMilli() {
|
||||
response.WriteJSON(w, response.Err(403, "Share expired"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(share.AllowedIPs) != "" {
|
||||
clientIP := resolvePeerClientIP(r)
|
||||
if clientIP == nil {
|
||||
@@ -847,10 +853,48 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
|
||||
if !isFederationCleanupRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "Share is inactive, expired, or over quota"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoked allocation privileges must not revoke cleanup privileges. Inspect
|
||||
// only supported destructive commands, preserving the body for the handler.
|
||||
func isFederationCleanupRequest(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/federation/runtime/release-role":
|
||||
return true
|
||||
case "/api/v1/federation/runtime/command":
|
||||
if r.Body == nil {
|
||||
return false
|
||||
}
|
||||
var copied bytes.Buffer
|
||||
original := r.Body
|
||||
var request federationRuntimeCommandRequest
|
||||
err := json.NewDecoder(io.TeeReader(io.LimitReader(original, 1<<20), &copied)).Decode(&request)
|
||||
r.Body = struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{Reader: io.MultiReader(&copied, original), Closer: original}
|
||||
if err != nil || !isFederationRuntimeCommandAllowed(request.CommandType) {
|
||||
return false
|
||||
}
|
||||
_, action := federationResourceCommandKind(request.CommandType)
|
||||
return action == "delete"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
@@ -982,8 +1026,6 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"tunnelId": tunnelID,
|
||||
}))
|
||||
@@ -1013,11 +1055,28 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
peerRoleRuntimeMu.Lock()
|
||||
defer peerRoleRuntimeMu.Unlock()
|
||||
share, err = h.repo.GetPeerShare(share.ID)
|
||||
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
|
||||
response.WriteJSON(w, response.Err(403, "Share is unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.repo.GetPeerShareRuntimeByResourceKey(share.ID, req.ResourceKey)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existing != nil && existing.ReleasePending != 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
|
||||
return
|
||||
}
|
||||
if existing != nil && existing.Status == 1 {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"reservationId": existing.ReservationID,
|
||||
@@ -1026,10 +1085,6 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
}))
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
|
||||
if err != nil {
|
||||
@@ -1039,6 +1094,7 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if existing != nil {
|
||||
existing.ReservationID = randomToken(24)
|
||||
existing.Protocol = defaultString(req.Protocol, "tls")
|
||||
existing.Port = allocatedPort
|
||||
existing.BindingID = ""
|
||||
@@ -1116,6 +1172,14 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
peerRoleRuntimeMu.Lock()
|
||||
defer peerRoleRuntimeMu.Unlock()
|
||||
share, err = h.repo.GetPeerShare(share.ID)
|
||||
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
|
||||
response.WriteJSON(w, response.ErrDefault("Share is unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
var runtime *repo.PeerShareRuntime
|
||||
if strings.TrimSpace(req.ReservationID) != "" {
|
||||
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
|
||||
@@ -1131,108 +1195,37 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||
strategy := defaultString(req.Strategy, "round")
|
||||
chainName := defaultString(runtime.ChainName, federationRuntimeChainName(runtime.BindingID))
|
||||
if chainName == "" {
|
||||
chainName = federationRuntimeChainName(fmt.Sprintf("%d", runtime.ID))
|
||||
}
|
||||
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
if runtime.Applied == 1 && strings.TrimSpace(runtime.BindingID) != "" {
|
||||
if req.Role == "middle" && len(req.Targets) > 0 {
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "UpdateChains", updateChainPayload(chainName, chainData), false, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
targetBytes, _ := json.Marshal(req.Targets)
|
||||
runtime.Role = req.Role
|
||||
runtime.ChainName = chainName
|
||||
runtime.Protocol = protocol
|
||||
runtime.Strategy = strategy
|
||||
runtime.Target = string(targetBytes)
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"bindingId": runtime.BindingID,
|
||||
"allocatedPort": runtime.Port,
|
||||
"reservationId": runtime.ReservationID,
|
||||
}))
|
||||
if runtime.ReleasePending != 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
|
||||
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
|
||||
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Role == "middle" {
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
targetCount := len(req.Targets)
|
||||
service := buildFederationServiceConfig(
|
||||
serviceName,
|
||||
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||
protocol,
|
||||
req.Role,
|
||||
chainName,
|
||||
targetCount,
|
||||
node.InterfaceName,
|
||||
)
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
|
||||
if req.Role == "middle" {
|
||||
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
if runtime.Role != "" && runtime.Role != req.Role {
|
||||
response.WriteJSON(w, response.ErrDefault("Runtime role cannot change without release"))
|
||||
return
|
||||
}
|
||||
|
||||
targetBytes, _ := json.Marshal(req.Targets)
|
||||
runtime.BindingID = fmt.Sprintf("%d", runtime.ID)
|
||||
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && (runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd) {
|
||||
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(runtime.BindingID) == "" {
|
||||
runtime.BindingID = randomToken(24)
|
||||
}
|
||||
runtime.Role = req.Role
|
||||
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
runtime.ChainName = ""
|
||||
if req.Role == "middle" {
|
||||
runtime.ChainName = chainName
|
||||
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
|
||||
}
|
||||
runtime.ServiceName = serviceName
|
||||
runtime.Protocol = protocol
|
||||
runtime.Strategy = strategy
|
||||
runtime.Protocol = defaultString(req.Protocol, runtime.Protocol)
|
||||
runtime.Strategy = defaultString(req.Strategy, "round")
|
||||
targetBytes, _ := json.Marshal(req.Targets)
|
||||
runtime.Target = string(targetBytes)
|
||||
runtime.Applied = 1
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1282,17 +1275,8 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.Applied == 1 {
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
_, _ = h.sendNodeCommand(share.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.repo.MarkPeerShareRuntimeReleased(runtime.ID, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
if err := h.releasePeerShareRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1385,6 +1369,15 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
h.peerResourceMu.Lock()
|
||||
defer h.peerResourceMu.Unlock()
|
||||
// Recheck after acquiring the mutation lock: an earlier authentication
|
||||
// decision cannot authorize a recreation after quota/expiry cleanup.
|
||||
share, err = h.repo.GetPeerShare(share.ID)
|
||||
if err != nil || share == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("share ownership unavailable"))
|
||||
return
|
||||
}
|
||||
if isFederationServiceCommand(cmd) {
|
||||
if err := validateFederationCommandPorts(share, req.Data); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
@@ -1392,17 +1385,35 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||
_, action := federationResourceCommandKind(cmd)
|
||||
if action != "delete" && (share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)) {
|
||||
response.WriteJSON(w, response.Err(403, "share is inactive, expired, or over quota"))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "tcpping") {
|
||||
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", req.Data, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
return
|
||||
}
|
||||
items, err := h.preparePeerResourceCommand(share, cmd, req.Data)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
|
||||
h.bindPeerShareForwardRuntimeServices(share, req.Data)
|
||||
} else if strings.EqualFold(cmd, "deleteservice") {
|
||||
h.releasePeerShareForwardRuntimeServices(share, req.Data)
|
||||
var result interface{} = map[string]interface{}{"success": true}
|
||||
for _, item := range items {
|
||||
res, err := h.applyPeerShareResource(item)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
result = res
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
type federationForwardServiceBinding struct {
|
||||
@@ -1436,10 +1447,15 @@ func parseFederationForwardServiceBindings(data interface{}) []federationForward
|
||||
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
|
||||
for _, svcMap := range serviceList {
|
||||
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
|
||||
originalName := name
|
||||
if shareID, original, ok := parsePeerShareServiceName(asString(svcMap["name"])); ok {
|
||||
originalName = normalizeForwardRuntimeServiceName(original)
|
||||
name = peerShareResourceName(shareID, "service", originalName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
if _, _, _, ok := parseFlowServiceIDs(originalName); !ok {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(asString(svcMap["addr"]))
|
||||
@@ -1498,29 +1514,29 @@ func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) error {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
bindings := parseFederationForwardServiceBindings(data)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, binding := range bindings {
|
||||
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
|
||||
if err != nil {
|
||||
continue
|
||||
return err
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
|
||||
if err := h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
@@ -1537,9 +1553,14 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if runtime.ReleasePending != 0 {
|
||||
return fmt.Errorf("runtime release is pending")
|
||||
}
|
||||
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
|
||||
continue
|
||||
}
|
||||
@@ -1554,8 +1575,11 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
|
||||
if strings.TrimSpace(runtime.Strategy) == "" {
|
||||
runtime.Strategy = "fifo"
|
||||
}
|
||||
_ = h.repo.UpdatePeerShareRuntime(runtime)
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
@@ -1575,7 +1599,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "updatechains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "addclimiters", "updateclimiters", "deleteclimiters", "tcpping":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1893,27 +1917,24 @@ func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
||||
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) error {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := h.releasePeerShareResources(shareID); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
|
||||
if err != nil || len(runtimes) == 0 {
|
||||
return
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, runtime := range runtimes {
|
||||
if h.wsServer != nil && runtime.Applied == 1 {
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||
}
|
||||
var cleanupErr error
|
||||
for i := range runtimes {
|
||||
if err := h.releasePeerShareRuntime(&runtimes[i]); err != nil {
|
||||
cleanupErr = errors.Join(cleanupErr, err)
|
||||
}
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupFederationTunnels(shareID int64) {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
func consumerCleanupFixture(t *testing.T, remoteURL string) (*repo.Repository, *Handler) {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "consumer-cleanup.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
if err := r.DB().Create(&model.Node{ID: 1, Name: "remote", Secret: "remote", ServerIP: "127.0.0.1", Port: "31000-31010", IsRemote: 1, Status: 1, RemoteURL: sql.NullString{String: remoteURL, Valid: true}, RemoteToken: sql.NullString{String: "token", Valid: true}}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 42, NodeID: 1, ChainType: 3, RemoteURL: remoteURL, ResourceKey: "tunnel:42:node:1:type:3:hop:0", RemoteBindingID: "binding-old", AllocatedPort: 31001, Status: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Tunnel{ID: 42, Name: "live tunnel", Type: 2, Protocol: "tls", Flow: 1, TrafficRatio: 1, Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, &Handler{repo: r, wsServer: ws.NewServer(r, "consumer-cleanup")}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateValidationPreservesSharedRuntime(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"id":42,"type":2,"name":"invalid update","inNodeId":[]}`,
|
||||
`{"id":42,"type":2,"name":"invalid update","inNodeId":[{"nodeId":999}],"outNodeId":[{"nodeId":1,"port":31001}]}`,
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
var releases atomic.Int64
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
releases.Add(1)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.tunnelUpdate(rec, req)
|
||||
var payload response.R
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := r.GetTunnelName(42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Code == 0 || releases.Load() != 0 || len(bindings) != 1 || name != "live tunnel" {
|
||||
t.Fatalf("invalid update changed runtime: code=%d releases=%d bindings=%v name=%s", payload.Code, releases.Load(), bindings, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationCleanupRetainsFailedBindingAndRetriesOnlyPending(t *testing.T) {
|
||||
var unavailable atomic.Bool
|
||||
unavailable.Store(true)
|
||||
var releases atomic.Int64
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
releases.Add(1)
|
||||
if unavailable.Load() {
|
||||
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 43, NodeID: 1, ChainType: 3, RemoteURL: remote.URL, RemoteBindingID: "unrelated", ResourceKey: "unrelated", Status: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(42); err == nil {
|
||||
t.Fatal("expected release failure")
|
||||
}
|
||||
pending, err := r.ListPendingFederationTunnelBindings()
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("lost pending cleanup: %v %v", pending, err)
|
||||
}
|
||||
if err := h.cleanupFederationRuntime(42); err == nil {
|
||||
t.Fatal("expected second release failure")
|
||||
}
|
||||
if releases.Load() != 2 {
|
||||
t.Fatalf("cleanup was not retried: %d", releases.Load())
|
||||
}
|
||||
unavailable.Store(false)
|
||||
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = r.ListPendingFederationTunnelBindings()
|
||||
if err != nil || len(pending) != 0 {
|
||||
t.Fatalf("completed cleanup remains: %v %v", pending, err)
|
||||
}
|
||||
active, err := r.ListActiveFederationTunnelBindingsByTunnel(43)
|
||||
if err != nil || len(active) != 1 || releases.Load() != 3 {
|
||||
t.Fatalf("retry touched active binding: %v %v calls=%d", active, err, releases.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDeleteReportsRemoteFailureAndKeepsTunnel(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
rec := httptest.NewRecorder()
|
||||
h.tunnelDelete(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", strings.NewReader(`{"id":42}`)))
|
||||
var payload response.R
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Code == 0 {
|
||||
t.Fatal("delete reported success before peer cleanup")
|
||||
}
|
||||
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
|
||||
t.Fatalf("lost tunnel: %s %v", name, err)
|
||||
}
|
||||
pending, err := r.ListPendingFederationTunnelBindings()
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("lost binding: %v %v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRollbackReleaseIsDurableAndRetryable(t *testing.T) {
|
||||
var unavailable atomic.Bool
|
||||
unavailable.Store(true)
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if unavailable.Load() {
|
||||
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
refs := []federationRuntimeReleaseRef{{RemoteURL: remote.URL, RemoteToken: "token", BindingID: "new-binding", ReservationID: "new-reservation", ResourceKey: "new-key"}}
|
||||
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
|
||||
t.Fatal("expected release error")
|
||||
}
|
||||
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
|
||||
t.Fatal("expected repeat error")
|
||||
}
|
||||
pending, err := r.ListPendingFederationReleases()
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("rollback queue lost or duplicated release: %v %v", pending, err)
|
||||
}
|
||||
unavailable.Store(false)
|
||||
// A new Handler has no in-memory state from the failed rollback.
|
||||
restarted := &Handler{repo: r}
|
||||
if err := restarted.retryPendingFederationRuntimeCleanup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = r.ListPendingFederationReleases()
|
||||
if err != nil || len(pending) != 0 {
|
||||
t.Fatalf("completed rollback remains queued: %v %v", pending, err)
|
||||
}
|
||||
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
|
||||
if err != nil || len(active) != 1 {
|
||||
t.Fatalf("rollback retry removed active binding: %v %v", active, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationApplyFailureReturnsReservationForRollback(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasSuffix(req.URL.Path, "reserve-port") {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"reservationId": "reserved", "bindingId": "binding", "allocatedPort": 31001}))
|
||||
return
|
||||
}
|
||||
http.Error(w, "apply failed", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer remote.Close()
|
||||
_, h := consumerCleanupFixture(t, remote.URL)
|
||||
state := &tunnelCreateState{TunnelID: 43, Type: 2, OutNodes: []tunnelRuntimeNode{{NodeID: 1, Port: 31001}}, Nodes: map[int64]*nodeRecord{1: {ID: 1, Name: "remote", IsRemote: 1, RemoteURL: remote.URL, RemoteToken: "token"}}}
|
||||
_, refs, err := h.applyFederationRuntime(state, "")
|
||||
if err == nil || len(refs) != 1 || refs[0].ReservationID != "reserved" {
|
||||
t.Fatalf("failed apply lost reservation: refs=%+v err=%v", refs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateDatabaseValidationPreservesSharedRuntime(t *testing.T) {
|
||||
var releases atomic.Int64
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
releases.Add(1)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.DB().Exec(`CREATE TRIGGER reject_tunnel_update BEFORE UPDATE ON tunnel BEGIN SELECT RAISE(ABORT, 'test constraint rejected'); END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
|
||||
var payload response.R
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
|
||||
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "test constraint rejected") || releases.Load() != 0 || len(active) != 1 {
|
||||
t.Fatalf("DB validation touched runtime: payload=%+v bindings=%v calls=%d err=%v", payload, active, releases.Load(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRemoteReleaseFailurePreservesMetadata(t *testing.T) {
|
||||
var requests atomic.Int64
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
requests.Add(1)
|
||||
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
|
||||
var payload response.R
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := r.GetTunnelName(42)
|
||||
pending, pendingErr := r.ListPendingFederationTunnelBindings()
|
||||
if err != nil || pendingErr != nil || payload.Code == 0 || name != "live tunnel" || requests.Load() != 1 || len(pending) != 1 {
|
||||
t.Fatalf("failed cleanup continued update: payload=%+v name=%s calls=%d pending=%v err=%v/%v", payload, name, requests.Load(), pending, err, pendingErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateChainWriteValidationPreservesSharedRuntime(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}))
|
||||
defer remote.Close()
|
||||
r, h := consumerCleanupFixture(t, remote.URL)
|
||||
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.DB().Exec(`CREATE TRIGGER reject_chain_insert BEFORE INSERT ON chain_tunnel BEGIN SELECT RAISE(ABORT, 'chain write rejected'); END`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
|
||||
var payload response.R
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
|
||||
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "chain write rejected") || calls.Load() != 0 || len(active) != 1 {
|
||||
t.Fatalf("chain validation touched runtime: payload=%+v active=%v calls=%d err=%v", payload, active, calls.Load(), err)
|
||||
}
|
||||
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
|
||||
t.Fatalf("preflight metadata update was committed: %s %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
const peerShareResourcePrefix = "peer-share-"
|
||||
|
||||
func peerShareResourceName(shareID int64, kind, original string) string {
|
||||
return fmt.Sprintf("%s%d-%s-%s", peerShareResourcePrefix, shareID, kind, base64.RawURLEncoding.EncodeToString([]byte(original)))
|
||||
}
|
||||
|
||||
func parsePeerShareServiceName(name string) (shareID int64, originalName string, ok bool) {
|
||||
if !strings.HasPrefix(name, peerShareResourcePrefix) {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(name, peerShareResourcePrefix), "-", 3)
|
||||
if len(parts) != 3 || parts[1] != "service" {
|
||||
return
|
||||
}
|
||||
shareID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return shareID, string(decoded), true
|
||||
}
|
||||
|
||||
func federationResourceCommandKind(cmd string) (kind, action string) {
|
||||
lower := strings.ToLower(cmd)
|
||||
for _, entry := range []struct{ suffix, kind string }{{"service", "service"}, {"chains", "chain"}, {"climiters", "climiter"}, {"limiters", "limiter"}} {
|
||||
if strings.HasSuffix(lower, entry.suffix) {
|
||||
return entry.kind, strings.TrimSuffix(lower, entry.suffix)
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Every peer-supplied reference is resolved within the same share namespace.
|
||||
// Composite traffic limiters use a comma-separated list in GOST.
|
||||
func scopePeerResourceReferences(value interface{}, shareID int64) {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, child := range v {
|
||||
if strings.EqualFold(key, "chains") {
|
||||
if names, ok := child.([]interface{}); ok {
|
||||
for i, name := range names {
|
||||
if raw, ok := name.(string); ok {
|
||||
names[i] = peerShareResourceName(shareID, "chain", raw)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
kind := ""
|
||||
switch strings.ToLower(key) {
|
||||
case "chain":
|
||||
kind = "chain"
|
||||
case "limiter":
|
||||
kind = "limiter"
|
||||
case "climiter":
|
||||
kind = "climiter"
|
||||
}
|
||||
if raw, ok := child.(string); ok && kind != "" && strings.TrimSpace(raw) != "" {
|
||||
names := strings.Split(raw, ",")
|
||||
for i, name := range names {
|
||||
names[i] = peerShareResourceName(shareID, kind, strings.TrimSpace(name))
|
||||
}
|
||||
v[key] = strings.Join(names, ",")
|
||||
} else {
|
||||
scopePeerResourceReferences(child, shareID)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range v {
|
||||
scopePeerResourceReferences(child, shareID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peerResourceDeletePayload(item repo.PeerShareResource) interface{} {
|
||||
if item.Kind == "service" {
|
||||
return map[string]interface{}{"services": []string{item.RuntimeName}}
|
||||
}
|
||||
key := item.Kind
|
||||
if key == "climiter" {
|
||||
key = "limiter"
|
||||
}
|
||||
return map[string]interface{}{key: item.RuntimeName}
|
||||
}
|
||||
func peerResourceDeleteCommand(kind string) string {
|
||||
switch kind {
|
||||
case "service":
|
||||
return "DeleteService"
|
||||
case "chain":
|
||||
return "DeleteChains"
|
||||
case "climiter":
|
||||
return "DeleteCLimiters"
|
||||
default:
|
||||
return "DeleteLimiters"
|
||||
}
|
||||
}
|
||||
|
||||
// preparePeerResourceCommand validates and writes desired state before the node
|
||||
// sees any command, closing the flow-report race even without a reservation.
|
||||
func (h *Handler) preparePeerResourceCommand(share *repo.PeerShare, cmd string, data interface{}) ([]repo.PeerShareResource, error) {
|
||||
kind, action := federationResourceCommandKind(cmd)
|
||||
if kind == "" {
|
||||
return nil, fmt.Errorf("command not allowed")
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var decoded interface{}
|
||||
if err = json.Unmarshal(raw, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var configs []map[string]interface{}
|
||||
var names []string
|
||||
setting := action == "add" || action == "update"
|
||||
if setting {
|
||||
if kind == "service" {
|
||||
configs = extractFederationServiceEntries(decoded)
|
||||
} else if m, ok := decoded.(map[string]interface{}); ok {
|
||||
if nested, ok := m["data"].(map[string]interface{}); ok {
|
||||
configs = []map[string]interface{}{nested}
|
||||
} else {
|
||||
configs = []map[string]interface{}{m}
|
||||
}
|
||||
}
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("resource configuration is required")
|
||||
}
|
||||
for _, c := range configs {
|
||||
names = append(names, strings.TrimSpace(asString(c["name"])))
|
||||
}
|
||||
} else {
|
||||
if kind == "service" {
|
||||
if m, ok := decoded.(map[string]interface{}); ok {
|
||||
for _, v := range asAnySlice(m["services"]) {
|
||||
names = append(names, strings.TrimSpace(asString(v)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if s, ok := decoded.(string); ok {
|
||||
names = []string{strings.TrimSpace(s)}
|
||||
} else if m, ok := decoded.(map[string]interface{}); ok {
|
||||
key := kind
|
||||
if key == "climiter" {
|
||||
key = "limiter"
|
||||
}
|
||||
names = []string{strings.TrimSpace(asString(m[key]))}
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("resource name is required")
|
||||
}
|
||||
}
|
||||
requestedNames := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
requestedNames[name] = true
|
||||
}
|
||||
items := make([]repo.PeerShareResource, 0, len(names))
|
||||
seen := map[string]bool{}
|
||||
for i, name := range names {
|
||||
if name == "" || strings.HasPrefix(name, peerShareResourcePrefix) {
|
||||
return nil, fmt.Errorf("invalid peer resource name")
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("duplicate resource name")
|
||||
}
|
||||
seen[name] = true
|
||||
old, err := h.repo.GetPeerShareResource(share.ID, kind, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := repo.PeerShareResource{ShareID: share.ID, NodeID: share.NodeID, Kind: kind, OriginalName: name, RuntimeName: peerShareResourceName(share.ID, kind, name), DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}
|
||||
if old != nil {
|
||||
item = *old
|
||||
item.Applied = 0
|
||||
item.UpdatedTime = time.Now().UnixMilli()
|
||||
}
|
||||
if setting {
|
||||
if old == nil && kind == "service" {
|
||||
legacy, err := h.peerShareLegacyServiceNames(share, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(legacy) > 0 {
|
||||
encoded, _ := json.Marshal(legacy)
|
||||
item.LegacyNames = string(encoded)
|
||||
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
|
||||
}
|
||||
}
|
||||
config := configs[i]
|
||||
if err := validatePeerResourceReferences(config, kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopePeerResourceReferences(config, share.ID)
|
||||
config["name"] = item.RuntimeName
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Config = string(encoded)
|
||||
item.DesiredState = "active"
|
||||
item.ReleaseLegacyFamily = false
|
||||
} else {
|
||||
if old == nil {
|
||||
// Delete callers send candidate base/TCP/UDP names. Missing names
|
||||
// are safe no-ops; never forward a raw legacy name to the node.
|
||||
if action == "delete" {
|
||||
if kind != "service" {
|
||||
continue
|
||||
}
|
||||
legacy, err := h.peerShareLegacyServiceNames(share, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
continue
|
||||
}
|
||||
encoded, _ := json.Marshal(legacy)
|
||||
item.LegacyNames = string(encoded)
|
||||
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
|
||||
} else {
|
||||
return nil, fmt.Errorf("service %q not found", name)
|
||||
}
|
||||
}
|
||||
if old != nil && old.DesiredState == "deleted" && action != "delete" {
|
||||
return nil, fmt.Errorf("service %q not found", name)
|
||||
}
|
||||
switch action {
|
||||
case "delete":
|
||||
item.DesiredState = "deleted"
|
||||
base := normalizeForwardRuntimeServiceName(name)
|
||||
if requestedNames[base] && requestedNames[base+"_tcp"] && requestedNames[base+"_udp"] {
|
||||
item.ReleaseLegacyFamily = true
|
||||
}
|
||||
case "pause":
|
||||
item.DesiredState = "paused"
|
||||
case "resume":
|
||||
if item.Config == "" {
|
||||
return nil, fmt.Errorf("resource has no saved configuration")
|
||||
}
|
||||
item.DesiredState = "active"
|
||||
default:
|
||||
return nil, fmt.Errorf("command not allowed")
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
// Ownership and desired config commit atomically. A failed resource write
|
||||
// must not rename a legacy binding and expose its remaining transports.
|
||||
err = h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
|
||||
if setting && kind == "service" {
|
||||
scoped := make([]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
var c map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(item.Config), &c)
|
||||
scoped = append(scoped, c)
|
||||
}
|
||||
binder := &Handler{repo: tx}
|
||||
if err := binder.bindPeerShareForwardRuntimeServices(share, scoped); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.SavePeerShareResources(items)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *Handler) applyPeerShareResource(item repo.PeerShareResource) (ws.CommandResult, error) {
|
||||
var result ws.CommandResult
|
||||
var err error
|
||||
if item.LegacyNames != "" {
|
||||
var names []string
|
||||
if err := json.Unmarshal([]byte(item.LegacyNames), &names); err != nil {
|
||||
return result, err
|
||||
}
|
||||
share, loadErr := h.repo.GetPeerShare(item.ShareID)
|
||||
if loadErr != nil {
|
||||
return result, loadErr
|
||||
}
|
||||
if share == nil {
|
||||
return result, fmt.Errorf("legacy resource ownership missing")
|
||||
}
|
||||
for _, name := range names {
|
||||
owned, checkErr := h.peerShareLegacyServiceNames(share, name)
|
||||
if checkErr != nil {
|
||||
return result, checkErr
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return result, fmt.Errorf("legacy resource ownership missing")
|
||||
}
|
||||
}
|
||||
if _, err = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err = h.repo.ClearPeerShareResourceLegacyNames(item.ShareID, item.Kind, item.OriginalName); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if item.DesiredState == "deleted" || item.DesiredState == "paused" {
|
||||
// The provider retains the paused configuration durably. Keep the
|
||||
// listener absent on reconnect instead of briefly starting it before
|
||||
// sending a second pause command. Resume reapplies the saved config.
|
||||
result, err = h.sendNodeCommand(item.NodeID, peerResourceDeleteCommand(item.Kind), peerResourceDeletePayload(item), false, true)
|
||||
} else {
|
||||
var config map[string]interface{}
|
||||
if err = json.Unmarshal([]byte(item.Config), &config); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if item.Kind == "service" {
|
||||
result, err = h.sendNodeCommand(item.NodeID, "UpdateService", []interface{}{config}, false, false)
|
||||
} else {
|
||||
suffix := "Limiters"
|
||||
key := "limiter"
|
||||
if item.Kind == "chain" {
|
||||
suffix = "Chains"
|
||||
key = "chain"
|
||||
}
|
||||
if item.Kind == "climiter" {
|
||||
suffix = "CLimiters"
|
||||
}
|
||||
result, err = h.sendNodeCommand(item.NodeID, "Add"+suffix, config, false, false)
|
||||
if err != nil && isAlreadyExistsMessage(err.Error()) {
|
||||
result, err = h.sendNodeCommand(item.NodeID, "Update"+suffix, map[string]interface{}{key: item.RuntimeName, "data": config}, false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if item.Kind == "service" && item.DesiredState == "deleted" {
|
||||
// TCP and UDP may share one reservation. Release only after all variants
|
||||
// have an acknowledged tombstone.
|
||||
items, listErr := h.repo.ListPeerShareResourcesByNode(item.NodeID)
|
||||
if listErr != nil {
|
||||
return result, listErr
|
||||
}
|
||||
base := normalizeForwardRuntimeServiceName(item.OriginalName)
|
||||
for _, other := range items {
|
||||
if other.ShareID == item.ShareID && other.Kind == "service" && other.OriginalName != item.OriginalName && normalizeForwardRuntimeServiceName(other.OriginalName) == base && (other.DesiredState != "deleted" || other.Applied == 0) {
|
||||
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
|
||||
}
|
||||
}
|
||||
legacyBase := item.LegacyServiceBase
|
||||
if legacyBase == "" {
|
||||
for _, other := range items {
|
||||
if other.ShareID == item.ShareID && normalizeForwardRuntimeServiceName(other.OriginalName) == base && other.LegacyServiceBase != "" {
|
||||
legacyBase = other.LegacyServiceBase
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if legacyBase != "" && item.ReleaseLegacyFamily {
|
||||
share, loadErr := h.repo.GetPeerShare(item.ShareID)
|
||||
if loadErr != nil {
|
||||
return result, loadErr
|
||||
}
|
||||
if share == nil {
|
||||
return result, fmt.Errorf("share ownership missing")
|
||||
}
|
||||
if _, checkErr := h.peerShareLegacyServiceNames(share, legacyBase); checkErr != nil {
|
||||
return result, checkErr
|
||||
}
|
||||
if _, deleteErr := h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": buildForwardServiceDeleteNames([]string{legacyBase})}, false, true); deleteErr != nil {
|
||||
return result, deleteErr
|
||||
}
|
||||
}
|
||||
if legacyBase != "" && !item.ReleaseLegacyFamily {
|
||||
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
|
||||
}
|
||||
runtimes, listErr := h.repo.ListActivePeerShareRuntimesByShareID(item.ShareID)
|
||||
if listErr != nil {
|
||||
return result, listErr
|
||||
}
|
||||
return result, h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
|
||||
for _, runtime := range runtimes {
|
||||
sid, original, scoped := parsePeerShareServiceName(runtime.ServiceName)
|
||||
ownedScoped := scoped && sid == item.ShareID && normalizeForwardRuntimeServiceName(original) == base
|
||||
ownedLegacy := !scoped && runtime.Role == "forward" && item.ReleaseLegacyFamily && legacyBase != "" && normalizeForwardRuntimeServiceName(runtime.ServiceName) == legacyBase
|
||||
if ownedScoped || ownedLegacy {
|
||||
if err = tx.CompletePeerShareRuntimeRelease(runtime.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if legacyBase != "" && item.ReleaseLegacyFamily {
|
||||
if err := tx.ClearPeerShareResourceLegacyFamily(item.ShareID, legacyBase); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
|
||||
})
|
||||
}
|
||||
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
|
||||
}
|
||||
|
||||
func orderPeerShareResources(items []repo.PeerShareResource) {
|
||||
rank := func(item repo.PeerShareResource) int {
|
||||
if item.DesiredState == "deleted" {
|
||||
if item.Kind == "service" {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if item.Kind == "service" {
|
||||
return 4
|
||||
}
|
||||
if item.Kind == "chain" {
|
||||
return 3
|
||||
}
|
||||
return 2
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool { return rank(items[i]) < rank(items[j]) })
|
||||
}
|
||||
|
||||
func (h *Handler) reconcilePeerShareResourcesOnNode(nodeID int64) error {
|
||||
return h.reconcilePeerShareResources(nodeID, false)
|
||||
}
|
||||
func (h *Handler) retryPendingPeerShareResourcesOnNode(nodeID int64) error {
|
||||
return h.reconcilePeerShareResources(nodeID, true)
|
||||
}
|
||||
func (h *Handler) reconcilePeerShareResources(nodeID int64, pendingOnly bool) error {
|
||||
h.peerResourceMu.Lock()
|
||||
defer h.peerResourceMu.Unlock()
|
||||
items, err := h.repo.ListPeerShareResourcesByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pending := map[string]bool{}
|
||||
for _, runtime := range runtimes {
|
||||
if runtime.ReleasePending != 0 {
|
||||
sid, name, ok := parsePeerShareServiceName(runtime.ServiceName)
|
||||
if ok {
|
||||
pending[fmt.Sprintf("%d:%s", sid, normalizeForwardRuntimeServiceName(name))] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
groups := map[int64][]repo.PeerShareResource{}
|
||||
var ids []int64
|
||||
for _, item := range items {
|
||||
if _, ok := groups[item.ShareID]; !ok {
|
||||
ids = append(ids, item.ShareID)
|
||||
}
|
||||
groups[item.ShareID] = append(groups[item.ShareID], item)
|
||||
}
|
||||
var failures []error
|
||||
for _, shareID := range ids {
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
expired := share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)
|
||||
group := groups[shareID]
|
||||
var changed []repo.PeerShareResource
|
||||
for i := range group {
|
||||
item := &group[i]
|
||||
if (expired || (item.Kind == "service" && pending[fmt.Sprintf("%d:%s", item.ShareID, normalizeForwardRuntimeServiceName(item.OriginalName))])) && (item.DesiredState != "deleted" || item.Applied == 0 || item.LegacyServiceBase != "") {
|
||||
item.DesiredState = "deleted"
|
||||
item.ReleaseLegacyFamily = true
|
||||
item.Applied = 0
|
||||
changed = append(changed, *item)
|
||||
}
|
||||
}
|
||||
if err := h.repo.SavePeerShareResources(changed); err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
orderPeerShareResources(group)
|
||||
dependencyFailed := false
|
||||
deletionFailed := false
|
||||
for _, item := range group {
|
||||
if item.Applied == 1 && (pendingOnly || item.DesiredState == "deleted") {
|
||||
continue
|
||||
}
|
||||
if dependencyFailed && item.Kind == "service" && item.DesiredState != "deleted" {
|
||||
continue
|
||||
}
|
||||
if deletionFailed && item.Kind != "service" && item.DesiredState == "deleted" {
|
||||
continue
|
||||
}
|
||||
if _, err := h.applyPeerShareResource(item); err != nil {
|
||||
failures = append(failures, fmt.Errorf("share %d %s %s: %w", item.ShareID, item.Kind, item.OriginalName, err))
|
||||
if item.Kind != "service" {
|
||||
dependencyFailed = true
|
||||
}
|
||||
if item.DesiredState == "deleted" {
|
||||
deletionFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareResources(shareID int64) error {
|
||||
h.peerResourceMu.Lock()
|
||||
defer h.peerResourceMu.Unlock()
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if share == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var items []repo.PeerShareResource
|
||||
for _, item := range all {
|
||||
if item.ShareID == shareID && !(item.DesiredState == "deleted" && item.Applied == 1 && item.LegacyServiceBase == "") {
|
||||
item.DesiredState = "deleted"
|
||||
item.ReleaseLegacyFamily = true
|
||||
item.Applied = 0
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
if err = h.repo.SavePeerShareResources(items); err != nil {
|
||||
return err
|
||||
}
|
||||
orderPeerShareResources(items)
|
||||
for _, item := range items {
|
||||
if _, err = h.applyPeerShareResource(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A legacy name is eligible for migration only when its runtime registration
|
||||
// proves unique ownership. An identically numbered local forward is ambiguous.
|
||||
func (h *Handler) peerShareLegacyServiceNames(share *repo.PeerShare, original string) ([]string, error) {
|
||||
base := normalizeForwardRuntimeServiceName(original)
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(share.NodeID, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned := false
|
||||
for _, runtime := range runtimes {
|
||||
if runtime.ShareID != share.ID {
|
||||
return nil, fmt.Errorf("legacy resource %q has ambiguous ownership", original)
|
||||
}
|
||||
if runtime.ReleasePending != 0 {
|
||||
return nil, fmt.Errorf("runtime release is pending")
|
||||
}
|
||||
owned = true
|
||||
}
|
||||
if !owned {
|
||||
resources, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.ShareID == share.ID && resource.LegacyServiceBase == base {
|
||||
owned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !owned {
|
||||
return nil, nil
|
||||
}
|
||||
if id, _, _, ok := parseFlowServiceIDs(base); ok {
|
||||
local, err := h.repo.GetForwardRecord(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if local != nil {
|
||||
return nil, fmt.Errorf("legacy resource %q collides with a local forward", original)
|
||||
}
|
||||
}
|
||||
// Only delete the requested transport. Other variants remain registered to
|
||||
// their legacy runtime until they are independently migrated.
|
||||
return []string{original}, nil
|
||||
}
|
||||
|
||||
// releasePeerShareForwardRuntimeResources removes exact persisted transport
|
||||
// names belonging to a single forward reservation, retaining failed tombstones.
|
||||
func (h *Handler) releasePeerShareForwardRuntimeResources(runtime *repo.PeerShareRuntime) error {
|
||||
h.peerResourceMu.Lock()
|
||||
defer h.peerResourceMu.Unlock()
|
||||
shareID, original, ok := parsePeerShareServiceName(runtime.ServiceName)
|
||||
if !ok || shareID != runtime.ShareID {
|
||||
return fmt.Errorf("runtime is not a scoped shared forward")
|
||||
}
|
||||
all, err := h.repo.ListPeerShareResourcesByNode(runtime.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var items []repo.PeerShareResource
|
||||
for _, item := range all {
|
||||
if item.ShareID == runtime.ShareID && item.Kind == "service" && normalizeForwardRuntimeServiceName(item.OriginalName) == normalizeForwardRuntimeServiceName(original) {
|
||||
item.DesiredState = "deleted"
|
||||
item.ReleaseLegacyFamily = true
|
||||
item.Applied = 0
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("shared forward resource ownership is missing")
|
||||
}
|
||||
if err = h.repo.SavePeerShareResources(items); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err = h.applyPeerShareResource(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registry kinds without an owned command API cannot be referenced by peers.
|
||||
func validatePeerResourceReferences(value interface{}, kind string) error {
|
||||
var walk func(interface{}) error
|
||||
walk = func(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, child := range v {
|
||||
switch strings.ToLower(key) {
|
||||
case "auther", "authers", "admission", "admissions", "bypass", "bypasses", "resolver", "hosts", "rlimiter", "logger", "loggers", "observer", "recorders", "hop", "sd":
|
||||
if child != nil {
|
||||
empty := false
|
||||
switch x := child.(type) {
|
||||
case string:
|
||||
empty = strings.TrimSpace(x) == ""
|
||||
case []interface{}:
|
||||
empty = len(x) == 0
|
||||
}
|
||||
if !empty {
|
||||
return fmt.Errorf("unsupported shared registry reference: %s", key)
|
||||
}
|
||||
}
|
||||
case "forwarder":
|
||||
if f, ok := child.(map[string]interface{}); ok {
|
||||
for field, value := range f {
|
||||
if strings.EqualFold(field, "name") && strings.TrimSpace(asString(value)) != "" {
|
||||
return fmt.Errorf("named shared forwarder references are unsupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "hops":
|
||||
for _, hop := range asMapSlice(child) {
|
||||
if _, ok := hop["nodes"]; !ok {
|
||||
return fmt.Errorf("shared chains require inline hop nodes")
|
||||
}
|
||||
for _, loader := range []string{"file", "redis", "http", "plugin"} {
|
||||
if hop[loader] != nil {
|
||||
return fmt.Errorf("shared hop loaders are unsupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range v {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if kind == "limiter" || kind == "climiter" {
|
||||
if config, ok := value.(map[string]interface{}); ok {
|
||||
for _, loader := range []string{"file", "redis", "http", "plugin"} {
|
||||
if config[loader] != nil {
|
||||
return fmt.Errorf("shared limiter loaders are unsupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func resourceTestShare(t *testing.T, h *Handler, token string) *repo.PeerShare {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
share := &repo.PeerShare{Name: token, NodeID: 1, Token: token, IsActive: 1, PortRangeStart: 31000, PortRangeEnd: 32000, CreatedTime: now, UpdatedTime: now}
|
||||
if err := h.repo.CreatePeerShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return share
|
||||
}
|
||||
func resourceTestCommand(t *testing.T, h *Handler, share *repo.PeerShare, cmd string, data interface{}) response.R {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(federationRuntimeCommandRequest{CommandType: cmd, Data: data})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+share.Token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.federationRuntimeCommand(rec, req)
|
||||
var result response.R
|
||||
if err = json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func resourceTestService(name string, port int) []interface{} {
|
||||
return []interface{}{map[string]interface{}{"name": name, "addr": fmt.Sprintf(":%d", port), "handler": map[string]interface{}{"type": "tcp", "chain": "70"}, "listener": map[string]interface{}{"type": "tcp"}, "limiter": "10,20", "climiter": "30"}}
|
||||
}
|
||||
|
||||
func TestPeerResourceCommandIsolationAndDurableRestore(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
first := resourceTestShare(t, a.h, "resource-first")
|
||||
second := resourceTestShare(t, a.h, "resource-second")
|
||||
for i, share := range []*repo.PeerShare{first, second} {
|
||||
for _, entry := range []struct{ cmd, name string }{{"AddLimiters", "10"}, {"AddCLimiters", "30"}, {"AddChains", "70"}} {
|
||||
result := resourceTestCommand(t, a.h, share, entry.cmd, map[string]interface{}{"name": entry.name})
|
||||
if result.Code != 0 {
|
||||
t.Fatal(result.Msg)
|
||||
}
|
||||
}
|
||||
result := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001+i))
|
||||
if result.Code != 0 {
|
||||
t.Fatal(result.Msg)
|
||||
}
|
||||
}
|
||||
commands := a.commandsOfType("UpdateService")
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("commands: %+v", commands)
|
||||
}
|
||||
for i, share := range []*repo.PeerShare{first, second} {
|
||||
var configs []map[string]interface{}
|
||||
if err := json.Unmarshal(commands[i].Data, &configs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := configs[0]
|
||||
if c["name"] != peerShareResourceName(share.ID, "service", "70_1_0_tcp") {
|
||||
t.Fatalf("unscoped service: %v", c)
|
||||
}
|
||||
if c["handler"].(map[string]interface{})["chain"] != peerShareResourceName(share.ID, "chain", "70") {
|
||||
t.Fatalf("unscoped chain: %v", c)
|
||||
}
|
||||
want := peerShareResourceName(share.ID, "limiter", "10") + "," + peerShareResourceName(share.ID, "limiter", "20")
|
||||
if c["limiter"] != want || c["climiter"] != peerShareResourceName(share.ID, "climiter", "30") {
|
||||
t.Fatalf("unscoped limiter: %v", c)
|
||||
}
|
||||
}
|
||||
result := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}})
|
||||
if result.Code != 0 {
|
||||
t.Fatal(result.Msg)
|
||||
}
|
||||
deleted := a.commandsOfType("DeleteService")
|
||||
if len(deleted) != 1 || !strings.Contains(string(deleted[0].Data), peerShareResourceName(first.ID, "service", "70_1_0_tcp")) {
|
||||
t.Fatalf("wrong deletion: %+v", deleted)
|
||||
}
|
||||
// A new Handler simulates restart with only durable desired state retained.
|
||||
restarted := &Handler{repo: a.h.repo, wsServer: a.h.wsServer}
|
||||
if err := restarted.reconcilePeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands = a.commandsOfType("UpdateService")
|
||||
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0_tcp")) {
|
||||
t.Fatalf("wrong recovery: %+v", commands)
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{peerShareResourceName(second.ID, "service", "70_1_0_tcp")}}); got.Code == 0 {
|
||||
t.Fatal("accepted foreign scoped name")
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, first, "Reload", nil); got.Code == 0 {
|
||||
t.Fatal("accepted global reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourcePreRegistrationAndDatabaseFailure(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-preregister")
|
||||
items, err := a.h.preparePeerResourceCommand(share, "AddService", resourceTestService("70_1_0_tcp", 31001))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("prepare sent service before persistence")
|
||||
}
|
||||
stored, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || stored == nil || stored.Applied != 0 {
|
||||
t.Fatalf("missing pending ownership: %+v %v", stored, err)
|
||||
}
|
||||
runCleanupPath(a.h, "single", []string{items[0].RuntimeName})
|
||||
a.probe(t)
|
||||
if len(a.commandsOfType("DeleteService")) != 0 {
|
||||
t.Fatal("flow report removed pending service")
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-resource-registration", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_resource" {
|
||||
tx.AddError(errors.New("simulated resource write failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.h.repo.DB().Callback().Create().Remove("fail-resource-registration")
|
||||
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002))
|
||||
if got.Code == 0 {
|
||||
t.Fatal("database failure returned success")
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("node received command after persistence failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceBindingDatabaseFailure(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-bind-failure")
|
||||
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-runtime-registration", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("simulated binding failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.h.repo.DB().Callback().Create().Remove("fail-runtime-registration")
|
||||
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001))
|
||||
if got.Code == 0 {
|
||||
t.Fatal("binding failure returned success")
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("service sent before successful binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceScopedNameRoundTrip(t *testing.T) {
|
||||
for _, name := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp", "a-b_c"} {
|
||||
scoped := peerShareResourceName(12, "service", name)
|
||||
id, got, ok := parsePeerShareServiceName(scoped)
|
||||
if !ok || id != 12 || got != name {
|
||||
t.Fatalf("round trip failed: %q", scoped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceDeletesCandidateNamesAndPreservesFailedTombstone(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-delete-candidates")
|
||||
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
|
||||
if got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
got = resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
|
||||
if got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
if len(a.commandsOfType("DeleteService")) != 1 {
|
||||
t.Fatal("unregistered names were sent to the node")
|
||||
}
|
||||
// A disconnected node cannot acknowledge deletion: keep the pending row.
|
||||
if got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
offline := &Handler{repo: a.h.repo}
|
||||
got = resourceTestCommand(t, offline, share, "DeleteService", map[string]interface{}{"services": []string{"71_1_0"}})
|
||||
if got.Code == 0 {
|
||||
t.Fatal("offline deletion reported success")
|
||||
}
|
||||
pending, err := a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
|
||||
if err != nil || pending.DesiredState != "deleted" || pending.Applied != 0 {
|
||||
t.Fatalf("lost pending deletion: %+v %v", pending, err)
|
||||
}
|
||||
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err = a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
|
||||
if err != nil || pending.Applied != 1 {
|
||||
t.Fatalf("deletion not retried: %+v %v", pending, err)
|
||||
}
|
||||
if got = resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"71_1_0"}}); got.Code == 0 {
|
||||
t.Fatal("resume resurrected a deleted resource")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceLegacyMigrationRequiresUnambiguousOwner(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
|
||||
if got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
deleted := a.commandsOfType("DeleteService")
|
||||
if len(deleted) != 1 || string(deleted[0].Data) != `{"services":["70_1_0_tcp"]}` {
|
||||
t.Fatalf("legacy deletion was not exact: %+v", deleted)
|
||||
}
|
||||
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || item.LegacyNames != "" {
|
||||
t.Fatalf("legacy migration acknowledgment not persisted: %+v %v", item, err)
|
||||
}
|
||||
// Two shares with the same legacy name are never resolved by guessing.
|
||||
other := resourceTestShare(t, a.h, "resource-ambiguous")
|
||||
now := time.Now().UnixMilli()
|
||||
for i, sid := range []int64{share.ID, other.ID} {
|
||||
if err := a.h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{ShareID: sid, NodeID: 1, ReservationID: fmt.Sprintf("ambiguous-%d", i), ResourceKey: fmt.Sprintf("ambiguous-%d", i), Role: "forward", ServiceName: "71_1_0", Port: 31003 + i, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0_tcp", 31003))
|
||||
if got.Code == 0 {
|
||||
t.Fatal("ambiguous legacy owner accepted")
|
||||
}
|
||||
if len(a.commandsOfType("DeleteService")) != 1 {
|
||||
t.Fatal("ambiguous legacy service deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceLegacyFamilyPersistsAcrossPartialMigration(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || item.LegacyServiceBase != "70_1_0" {
|
||||
t.Fatalf("lost legacy family ownership: %+v %v", item, err)
|
||||
}
|
||||
// A later request can still prove ownership of the old UDP transport.
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_udp", 31001)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
deletes := a.commandsOfType("DeleteService")
|
||||
if len(deletes) != 2 || string(deletes[1].Data) != `{"services":["70_1_0_udp"]}` {
|
||||
t.Fatalf("lost UDP migration ownership: %+v", deletes)
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}}); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || item.LegacyServiceBase != "" {
|
||||
t.Fatalf("legacy family not released: %+v %v", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceFailedRegistrationDoesNotRenameLegacyRuntime(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-atomic-resource", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_resource" {
|
||||
tx.AddError(errors.New("simulated desired-state failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.h.repo.DB().Callback().Create().Remove("fail-atomic-resource")
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code == 0 {
|
||||
t.Fatal("registration failure returned success")
|
||||
}
|
||||
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
|
||||
if err != nil || len(runtimes) != 1 || runtimes[0].ServiceName != "70_1_0" {
|
||||
t.Fatalf("legacy binding changed after rollback: %+v %v", runtimes, err)
|
||||
}
|
||||
if len(a.commandsOfType("DeleteService"))+len(a.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("node mutated despite transaction rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourcePausedReconcileNeverStartsListener(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-pause-recovery")
|
||||
for _, cmd := range []string{"AddService", "PauseService"} {
|
||||
var data interface{} = resourceTestService("70_1_0_tcp", 31001)
|
||||
if cmd == "PauseService" {
|
||||
data = map[string]interface{}{"services": []string{"70_1_0_tcp"}}
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, cmd, data); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
}
|
||||
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 1 {
|
||||
t.Fatal("paused listener started during reconciliation")
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 2 {
|
||||
t.Fatal("resume did not restore saved service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceDeleteUnmigratedLegacyOwner(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
|
||||
if got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
removed := false
|
||||
for _, cmd := range a.commandsOfType("DeleteService") {
|
||||
var body struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err = json.Unmarshal(cmd.Data, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range body.Services {
|
||||
if name == "70_1_0" {
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
t.Fatal("legacy delete reported success without sending old service deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourcePartialDeletePreservesLegacyFamily(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || item.LegacyServiceBase != "70_1_0" {
|
||||
t.Fatalf("partial delete lost remaining legacy family: %+v %v", item, err)
|
||||
}
|
||||
for _, cmd := range a.commandsOfType("DeleteService") {
|
||||
if strings.Contains(string(cmd.Data), `"70_1_0_udp"`) {
|
||||
t.Fatal("partial TCP delete removed legacy UDP")
|
||||
}
|
||||
}
|
||||
if err = a.h.releasePeerShareResources(share.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || item.LegacyServiceBase != "" {
|
||||
t.Fatalf("full release lost legacy cleanup: %+v %v", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceChainGroupsAreScopedAndOtherRegistriesRejected(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-chain-groups")
|
||||
data := resourceTestService("70_1_0", 31001)
|
||||
data[0].(map[string]interface{})["handler"].(map[string]interface{})["chainGroup"] = map[string]interface{}{"chains": []string{"one", "two"}}
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
body := string(a.commandsOfType("UpdateService")[0].Data)
|
||||
for _, name := range []string{"one", "two"} {
|
||||
if !strings.Contains(body, peerShareResourceName(share.ID, "chain", name)) {
|
||||
t.Fatalf("chainGroup reference was not scoped: %s", body)
|
||||
}
|
||||
}
|
||||
for _, reference := range []string{"resolver", "auther", "observer", "hop"} {
|
||||
data := resourceTestService("71_1_0", 31002)
|
||||
data[0].(map[string]interface{})[reference] = "global-resource"
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code == 0 {
|
||||
t.Fatalf("accepted global %s reference", reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceReconcileContinuesAfterAnotherShareFails(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
first := resourceTestShare(t, a.h, "resource-failed-share")
|
||||
second := resourceTestShare(t, a.h, "resource-good-share")
|
||||
for i, share := range []*repo.PeerShare{first, second} {
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001+i)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
}
|
||||
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: first.ID, NodeID: 1, Kind: "limiter", OriginalName: "broken", RuntimeName: peerShareResourceName(first.ID, "limiter", "broken"), Config: "{", DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.reconcilePeerShareResourcesOnNode(1); err == nil {
|
||||
t.Fatal("invalid dependency was not reported")
|
||||
}
|
||||
commands := a.commandsOfType("UpdateService")
|
||||
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0")) {
|
||||
t.Fatalf("failed share blocked healthy share, or failed dependency service was started: %+v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceRechecksShareBeforeRecreation(t *testing.T) {
|
||||
for _, state := range []string{"inactive", "expired", "exceeded"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-state-"+state)
|
||||
switch state {
|
||||
case "inactive":
|
||||
share.IsActive = 0
|
||||
case "expired":
|
||||
share.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli()
|
||||
case "exceeded":
|
||||
share.MaxBandwidth = 1
|
||||
share.CurrentFlow = 1 << 40
|
||||
}
|
||||
if err := a.h.repo.UpdatePeerShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state == "exceeded" {
|
||||
if err := a.h.repo.AddPeerShareCurrentFlow(share.ID, share.CurrentFlow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
|
||||
t.Fatal("invalid share recreated resources")
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("invalid share reached the node")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "resource-pending-only")
|
||||
offline := &Handler{repo: a.h.repo}
|
||||
if got := resourceTestCommand(t, offline, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
|
||||
t.Fatal("offline apply reported success")
|
||||
}
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands := a.commandsOfType("UpdateService")
|
||||
if len(commands) != 2 || !strings.Contains(string(commands[1].Data), peerShareResourceName(share.ID, "service", "70_1_0")) {
|
||||
t.Fatalf("pending retry restarted applied sibling: %+v", commands)
|
||||
}
|
||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.commandsOfType("UpdateService")) != 2 {
|
||||
t.Fatal("no-op pending retry restarted applied services")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("simulated completion failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}})
|
||||
if got.Code == 0 {
|
||||
t.Fatal("completion database failure reported success")
|
||||
}
|
||||
items, err := a.h.repo.ListPeerShareResourcesByNode(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proof := false
|
||||
pending := false
|
||||
for _, item := range items {
|
||||
proof = proof || item.LegacyServiceBase == "70_1_0"
|
||||
pending = pending || item.Applied == 0
|
||||
}
|
||||
if !proof || !pending {
|
||||
t.Fatalf("failure lost ownership or retry marker: %+v", items)
|
||||
}
|
||||
if err := a.h.repo.DB().Callback().Update().Remove("fail-runtime-release"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
|
||||
if err != nil || len(runtimes) != 0 {
|
||||
t.Fatalf("retry leaked legacy reservation: %+v %v", runtimes, err)
|
||||
}
|
||||
}
|
||||
@@ -224,18 +224,14 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
agent := newCleanupAgent(t)
|
||||
r := agent.h.repo
|
||||
h := agent.h
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-cleanup-share",
|
||||
NodeID: 99,
|
||||
NodeID: 1,
|
||||
Token: "delete-cleanup-token",
|
||||
MaxBandwidth: 4096,
|
||||
PortRangeStart: 40000,
|
||||
@@ -257,8 +253,8 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
|
||||
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
|
||||
share.ID, 1, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
|
||||
share.ID, 1, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type cleanupAgentCommand struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// cleanupAgent exercises the actual command transport. Each command is recorded
|
||||
// before its ACK, so synchronous handler calls need no sleeps to inspect it.
|
||||
type cleanupAgent struct {
|
||||
h *Handler
|
||||
mu sync.Mutex
|
||||
commands []cleanupAgentCommand
|
||||
}
|
||||
|
||||
func newCleanupAgent(t *testing.T) *cleanupAgent {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
const secret = "cleanup-regression-node"
|
||||
if err := r.DB().Create(&model.Node{ID: 1, Name: "relay", Secret: secret, ServerIP: "127.0.0.1", Port: "30000", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := ws.NewServer(r, "test-jwt-secret")
|
||||
online := make(chan struct{})
|
||||
server.SetNodeOnlineHook(func(int64) { close(online) })
|
||||
serverDone := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
defer close(serverDone)
|
||||
server.ServeHTTP(w, req)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http")+"?type=1&secret="+secret, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &cleanupAgent{h: &Handler{repo: r, wsServer: server}}
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readerDone)
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var envelope struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
t.Errorf("decode command envelope: %v", err)
|
||||
return
|
||||
}
|
||||
if envelope.Encrypted {
|
||||
payload, err = crypto.Decrypt(envelope.Data)
|
||||
if err != nil {
|
||||
t.Errorf("decrypt command: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var command cleanupAgentCommand
|
||||
if err := json.Unmarshal(payload, &command); err != nil {
|
||||
t.Errorf("decode command: %v", err)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.commands = append(a.commands, command)
|
||||
a.mu.Unlock()
|
||||
if err := conn.WriteJSON(map[string]interface{}{"type": command.Type, "requestId": command.RequestID, "success": true}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
<-readerDone
|
||||
<-serverDone
|
||||
})
|
||||
select {
|
||||
case <-online:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("mock node did not come online")
|
||||
}
|
||||
a.probe(t)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *cleanupAgent) probe(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := a.h.wsServer.SendCommand(1, "CleanupTestProbe", nil, time.Second); err != nil {
|
||||
t.Fatalf("mock agent transport unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cleanupAgent) commandsOfType(commandType string) []cleanupAgentCommand {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
var commands []cleanupAgentCommand
|
||||
for _, command := range a.commands {
|
||||
if command.Type == commandType {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func (a *cleanupAgent) addRuntime(t *testing.T, name string, nodeID int64, status, applied int, updated time.Time) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
share := &repo.PeerShare{Name: "shared", NodeID: nodeID, Token: "cleanup-share-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
|
||||
if err := a.h.repo.CreatePeerShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := a.h.repo.GetPeerShareByToken(share.Token)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
// SQL preserves status=0; GORM's default tag would replace that with 1.
|
||||
if err := a.h.repo.DB().Exec(`INSERT INTO peer_share_runtime
|
||||
(share_id, node_id, reservation_id, resource_key, role, service_name, applied, status, created_time, updated_time)
|
||||
VALUES (?, ?, 'cleanup-reservation', 'cleanup-resource', 'forward', ?, ?, ?, ?, ?)`,
|
||||
stored.ID, nodeID, name, applied, status, now, updated.UnixMilli()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stored.ID
|
||||
}
|
||||
|
||||
func TestSharedForwardCleanupRegression(t *testing.T) {
|
||||
for _, mode := range []string{"single", "batch", "config"} {
|
||||
for _, runtimeName := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"} {
|
||||
t.Run(mode+"/active/"+runtimeName, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, runtimeName, 1, 1, 1, time.Now())
|
||||
runCleanupPath(a.h, mode, []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("active shared service family was deleted: %+v", commands)
|
||||
}
|
||||
if mode != "config" && runtimeName == "70_1_0" {
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil || share.CurrentFlow != 600 {
|
||||
t.Fatalf("shared traffic should accumulate 600 bytes: share=%+v err=%v", share, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
serviceName string
|
||||
nodeID int64
|
||||
status int
|
||||
applied int
|
||||
age time.Duration
|
||||
wantDelete bool
|
||||
}{
|
||||
{name: "recent-unbound", nodeID: 1, status: 1, age: time.Minute},
|
||||
{name: "stale-unbound", nodeID: 1, status: 1, age: 11 * time.Minute, wantDelete: true},
|
||||
{name: "released", serviceName: "70_1_0", nodeID: 1, applied: 1, wantDelete: true},
|
||||
{name: "other-node-bound", serviceName: "70_1_0", nodeID: 2, status: 1, applied: 1, wantDelete: true},
|
||||
{name: "other-node-unbound", nodeID: 2, status: 1, wantDelete: true},
|
||||
{name: "unrelated-active-family", serviceName: "71_1_0", nodeID: 1, status: 1, applied: 1, wantDelete: true},
|
||||
} {
|
||||
t.Run(mode+"/"+tc.name, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.addRuntime(t, tc.serviceName, tc.nodeID, tc.status, tc.applied, time.Now().Add(-tc.age))
|
||||
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
|
||||
a.probe(t)
|
||||
commands := a.commandsOfType("DeleteService")
|
||||
if !tc.wantDelete {
|
||||
if len(commands) != 0 {
|
||||
t.Fatalf("recent unbound shared runtime was deleted: %+v", commands)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected one orphan cleanup command, got %+v", commands)
|
||||
}
|
||||
var data struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(data.Services)
|
||||
if want := []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}; !reflect.DeepEqual(data.Services, want) {
|
||||
t.Fatalf("orphan cleanup must delete complete family: got %v, want %v", data.Services, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCleanupPath(h *Handler, mode string, names []string) {
|
||||
items := make([]flowItem, 0, len(names))
|
||||
services := make([]namedConfigItem, 0, len(names))
|
||||
for _, name := range names {
|
||||
items = append(items, flowItem{N: name, U: 120, D: 80})
|
||||
services = append(services, namedConfigItem{Name: name})
|
||||
}
|
||||
switch mode {
|
||||
case "single":
|
||||
for _, item := range items {
|
||||
h.processFlowItem(1, item)
|
||||
}
|
||||
case "batch":
|
||||
h.applyFlowUploadBatch(1, h.buildNodeFlowUploadBatch(1, items, nil), time.Now())
|
||||
case "config":
|
||||
h.cleanOrphanedServices(1, services)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedForwardCleanupMissingMetadataPreservesLocalForward(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runCleanupPath(a.h, "batch", []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("missing batch metadata must not delete a local forward: %+v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedForwardCleanupChecksActualDeleteFamily(t *testing.T) {
|
||||
for _, mode := range []string{"single", "batch", "config"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
// Legacy parsing accepts additional suffixes. Cleanup must check
|
||||
// the family it would delete, not just the reported service name.
|
||||
runCleanupPath(a.h, mode, []string{"70_1_0_old", "70_1_0_old_tcp"})
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("suffix variation must not delete a protected shared family: %+v", commands)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedForwardCleanupQueryFailurePreservesServices(t *testing.T) {
|
||||
for _, mode := range []string{"single", "batch", "config"} {
|
||||
for _, failure := range []string{"forward", "peer_share_runtime"} {
|
||||
t.Run(mode+"/"+failure, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
// Fail only the ownership lookup; leave node lookup and WebSocket
|
||||
// delivery functional so an erroneous delete remains observable.
|
||||
callback := "test:cleanup-query-failure"
|
||||
var injected atomic.Int32
|
||||
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == failure {
|
||||
injected.Add(1)
|
||||
tx.AddError(errors.New("injected ownership query failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
|
||||
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
|
||||
a.probe(t)
|
||||
if injected.Load() == 0 {
|
||||
t.Fatal("test did not inject the expected query failure")
|
||||
}
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("ownership query failure must preserve services: %+v", commands)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestConfigCleanupPreservesSharedDependencies(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
a.h.cleanNodeConfigs(1, `{
|
||||
"services": [{"name":"70_1_0_tcp", "handler":{"chain":"chains_88"}, "limiter":"13, rule_traffic_limit_70"}],
|
||||
"chains": [{"name":"fed_chain_17"}, {"name":"chains_88"}, {"name":"chains_999"}],
|
||||
"limiters": [{"name":"13"}, {"name":"rule_traffic_limit_70"}, {"name":"99"}]
|
||||
}`)
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("shared service was deleted: %+v", commands)
|
||||
}
|
||||
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_999")
|
||||
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "99")
|
||||
}
|
||||
|
||||
func TestConfigCleanupRemovesOrphanedForwardLimiter(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.h.cleanNodeConfigs(1, `{"limiters":[{"name":"rule_traffic_limit_70"}]}`)
|
||||
a.probe(t)
|
||||
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "rule_traffic_limit_70")
|
||||
}
|
||||
|
||||
func TestConfigCleanupProtectsPendingSharedDependencies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
keep bool
|
||||
}{
|
||||
{name: "pending", age: time.Minute, keep: true},
|
||||
{name: "expired-reservation", age: 11 * time.Minute},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.addRuntime(t, "", 1, 1, 0, time.Now().Add(-tc.age))
|
||||
// Dependencies may arrive before the service and its runtime binding.
|
||||
a.h.cleanNodeConfigs(1, `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`)
|
||||
a.probe(t)
|
||||
if tc.keep {
|
||||
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
|
||||
if commands := a.commandsOfType(commandType); len(commands) != 0 {
|
||||
t.Fatalf("pending shared dependencies deleted: %+v", commands)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_88")
|
||||
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "13")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCleanupPreservesDependenciesOnLookupFailure(t *testing.T) {
|
||||
for _, table := range []string{"peer_share_runtime", "tunnel", "speed_limit", "forward"} {
|
||||
t.Run(table, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
callback := "test:config-cleanup-query-failure"
|
||||
injected := false
|
||||
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == table {
|
||||
injected = true
|
||||
tx.AddError(errors.New("injected dependency lookup failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
|
||||
configs := map[string]string{
|
||||
"peer_share_runtime": `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`,
|
||||
"tunnel": `{"chains":[{"name":"chains_88"}]}`,
|
||||
"speed_limit": `{"limiters":[{"name":"13"}]}`,
|
||||
"forward": `{"limiters":[{"name":"rule_traffic_limit_70"}]}`,
|
||||
}
|
||||
a.h.cleanNodeConfigs(1, configs[table])
|
||||
a.probe(t)
|
||||
if !injected {
|
||||
t.Fatal("expected dependency lookup failure to be injected")
|
||||
}
|
||||
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
|
||||
if commands := a.commandsOfType(commandType); len(commands) != 0 {
|
||||
t.Fatalf("lookup failure must not authorize cleanup: %+v", commands)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedForwardCleanupDuringRuntimeBinding(t *testing.T) {
|
||||
for _, mode := range []string{"single", "batch", "config"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
a.addRuntime(t, "", 1, 1, 0, time.Now())
|
||||
callback := "test:bind-during-cleanup"
|
||||
bound := false
|
||||
if err := a.h.repo.DB().Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "peer_share_runtime" || bound {
|
||||
return
|
||||
}
|
||||
bound = true
|
||||
// Reproduce binding immediately after the first ownership read.
|
||||
// Separate name/unbound queries would both miss this runtime.
|
||||
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET service_name = ?, applied = 1", "70_1_0").Error; err != nil {
|
||||
t.Errorf("bind shared runtime: %v", err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
|
||||
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
|
||||
a.probe(t)
|
||||
if !bound {
|
||||
t.Fatal("binding transition did not run")
|
||||
}
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("service was deleted during binding: %+v", commands)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCleanupDependency(t *testing.T, a *cleanupAgent, commandType, key, want string) {
|
||||
t.Helper()
|
||||
commands := a.commandsOfType(commandType)
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected one %s for %s, got %+v", commandType, want, commands)
|
||||
}
|
||||
var data map[string]string
|
||||
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data[key] != want {
|
||||
t.Fatalf("unexpected %s target: got %q, want %q", commandType, data[key], want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
// Classify ownership before building local counters or enforcing local quotas.
|
||||
// Numeric IDs from a consuming panel are not IDs in the provider's database.
|
||||
func (h *Handler) buildNodeFlowUploadBatch(nodeID int64, items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 {
|
||||
return flowUploadBatch{}
|
||||
}
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("flow ownership lookup failed node_id=%d err=%v", nodeID, err)
|
||||
return flowUploadBatch{}
|
||||
}
|
||||
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("flow resource lookup failed node_id=%d err=%v", nodeID, err)
|
||||
return flowUploadBatch{}
|
||||
}
|
||||
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("flow node ownership lookup failed node_id=%d err=%v", nodeID, err)
|
||||
return flowUploadBatch{}
|
||||
}
|
||||
nodeForwards := make(map[int64]struct{}, len(forwardIDs))
|
||||
for _, forwardID := range forwardIDs {
|
||||
nodeForwards[forwardID] = struct{}{}
|
||||
}
|
||||
legacyOwners := make(map[string]map[int64]struct{})
|
||||
addLegacyOwner := func(name string, shareID int64) {
|
||||
name = normalizeForwardRuntimeServiceName(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if legacyOwners[name] == nil {
|
||||
legacyOwners[name] = make(map[int64]struct{})
|
||||
}
|
||||
legacyOwners[name][shareID] = struct{}{}
|
||||
}
|
||||
for _, runtime := range runtimes {
|
||||
addLegacyOwner(runtime.ServiceName, runtime.ShareID)
|
||||
}
|
||||
resourceOwners := make(map[string]int64)
|
||||
for _, resource := range resources {
|
||||
// A migrated TCP service may still own a legacy UDP sibling. This alias
|
||||
// remains authoritative until the whole legacy family is acknowledged gone.
|
||||
addLegacyOwner(resource.LegacyServiceBase, resource.ShareID)
|
||||
// A deletion intent does not prove the listener is gone. Failed or
|
||||
// timed-out commands retain ownership until the node acknowledges it.
|
||||
if resource.Kind == "service" && (resource.DesiredState != "deleted" || resource.Applied == 0) {
|
||||
resourceOwners[resource.RuntimeName] = resource.ShareID
|
||||
}
|
||||
}
|
||||
localItems := make([]flowItem, 0, len(items))
|
||||
sharedUsage := make(map[int64]int64)
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if strings.HasPrefix(name, "peer-share-") {
|
||||
shareID, _, ok := parsePeerShareServiceName(name)
|
||||
if ok && resourceOwners[name] == shareID && item.U >= 0 && item.D >= 0 {
|
||||
sharedUsage[shareID] += item.U + item.D
|
||||
}
|
||||
// Unknown or confirmed-deleted scoped names must never become local IDs.
|
||||
continue
|
||||
}
|
||||
if owners := legacyOwners[normalizeForwardRuntimeServiceName(name)]; len(owners) > 0 {
|
||||
forwardID, userID, userTunnelID, parsed := parseFlowServiceIDs(name)
|
||||
meta, local := metas[forwardID]
|
||||
_, onNode := nodeForwards[forwardID]
|
||||
if parsed && local && onNode && meta.UserID == userID && meta.UserTunnelID == userTunnelID {
|
||||
// Legacy names can be genuinely ambiguous. Preserve the listener
|
||||
// but do not debit either owner based on an ID guess.
|
||||
log.Printf("ambiguous legacy flow ownership node_id=%d service=%s", nodeID, name)
|
||||
continue
|
||||
}
|
||||
if len(owners) == 1 && item.U >= 0 && item.D >= 0 {
|
||||
for shareID := range owners {
|
||||
sharedUsage[shareID] += item.U + item.D
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if forwardID, userID, _, ok := parseFlowServiceIDs(name); ok {
|
||||
if meta, exists := metas[forwardID]; exists {
|
||||
if _, onNode := nodeForwards[forwardID]; !onNode || meta.UserID != userID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
localItems = append(localItems, item)
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(localItems, metas)
|
||||
batch.peerShareUsage = sharedUsage
|
||||
return batch
|
||||
}
|
||||
|
||||
func (h *Handler) addPeerShareFlow(nodeID, shareID, delta int64) {
|
||||
if nodeID <= 0 || shareID <= 0 || delta <= 0 {
|
||||
return
|
||||
}
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil || share.NodeID != nodeID {
|
||||
return
|
||||
}
|
||||
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||
return
|
||||
}
|
||||
share, err = h.repo.GetPeerShare(shareID)
|
||||
if err == nil && isPeerShareFlowExceeded(share) {
|
||||
h.enforcePeerShareFlowLimit(shareID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFlowOwnershipSharedFlowDoesNotChargeLocalCollision(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 9, Name: "local", TrafficRatio: 1, Flow: 1, Type: 1, Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 2, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas), time.Now())
|
||||
var local model.Forward
|
||||
if err := a.h.repo.DB().First(&local, 70).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("local user=%d local bytes=%d shared bytes=%d", local.UserID, local.InFlow+local.OutFlow, shared.CurrentFlow)
|
||||
if local.InFlow+local.OutFlow != 0 {
|
||||
t.Errorf("shared flow charged colliding local forward: %d", local.InFlow+local.OutFlow)
|
||||
}
|
||||
if shared.CurrentFlow != 200 {
|
||||
t.Errorf("shared flow missing: %d", shared.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipScopedServiceRequiresStoredNodeOwnership(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
name := peerShareResourceName(shareID, "service", "70_1_0_tcp")
|
||||
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
|
||||
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
|
||||
RuntimeName: name, DesiredState: "active", Applied: 1,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, nodeID := range []int64{2, 1} {
|
||||
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: name, U: 120, D: 80}}, nil)
|
||||
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(0)
|
||||
if nodeID == 1 {
|
||||
want = 200
|
||||
}
|
||||
if share.CurrentFlow != want {
|
||||
t.Fatalf("node=%d: got flow=%d want=%d", nodeID, share.CurrentFlow, want)
|
||||
}
|
||||
if len(batch.flowDeltas) != 0 || len(batch.quotaUsage) != 0 || len(batch.orphanServices) != 0 {
|
||||
t.Fatalf("scoped traffic entered local accounting: %+v", batch)
|
||||
}
|
||||
}
|
||||
a.probe(t)
|
||||
if cmds := a.commandsOfType("DeleteService"); len(cmds) != 0 {
|
||||
t.Fatalf("scoped service deleted: %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipRoleRuntimeRequiresReportingNode(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "fed_svc_17", 1, 1, 1, time.Now())
|
||||
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET id = 17, role = 'exit'").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.h.processFlowItem(2, flowItem{N: "fed_svc_17", U: 120, D: 80})
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if share.CurrentFlow != 0 {
|
||||
t.Fatalf("wrong node charged role runtime: %d", share.CurrentFlow)
|
||||
}
|
||||
a.h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 120, D: 80})
|
||||
share, err = a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if share.CurrentFlow != 200 {
|
||||
t.Fatalf("owner node flow missing: %d", share.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipAmbiguousLegacyNameDoesNotDebitEitherOwner(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 1, Port: 32000}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
|
||||
a.h.applyFlowUploadBatch(1, batch, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if share.CurrentFlow != 0 || len(batch.flowDeltas) != 0 {
|
||||
t.Fatalf("ambiguous flow charged an owner: share=%d local=%+v", share.CurrentFlow, batch.flowDeltas)
|
||||
}
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("ambiguous legacy service deleted: %+v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipPreservesUnmigratedLegacyTransport(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, peerShareResourceName(1, "service", "70_1_0"), 1, 1, 1, time.Now())
|
||||
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
|
||||
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
|
||||
RuntimeName: peerShareResourceName(shareID, "service", "70_1_0_tcp"),
|
||||
LegacyServiceBase: "70_1_0", DesiredState: "deleted", Applied: 1,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.h.processFlowItem(1, flowItem{N: "70_1_0_udp", U: 120, D: 80})
|
||||
a.h.cleanNodeConfigs(1, `{"services":[{"name":"70_1_0_udp"}]}`)
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
|
||||
t.Fatalf("unmigrated transport deleted: %+v", commands)
|
||||
}
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if share.CurrentFlow != 200 {
|
||||
t.Fatalf("legacy transport accounting lost: %d", share.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipLocalForwardRequiresReportingNode(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 2, Port: 32000}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, nodeID := range []int64{1, 2} {
|
||||
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
|
||||
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
|
||||
var forward model.Forward
|
||||
if err := a.h.repo.DB().First(&forward, 70).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(0)
|
||||
if nodeID == 2 {
|
||||
want = 200
|
||||
}
|
||||
if forward.InFlow+forward.OutFlow != want {
|
||||
t.Fatalf("node %d local traffic=%d want=%d", nodeID, forward.InFlow+forward.OutFlow, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareMaintenanceRetriesOnlyUnfinishedOperations(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
name := peerShareResourceName(shareID, "service", "80_1_0_tcp")
|
||||
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: shareID, NodeID: 1,
|
||||
Kind: "service", OriginalName: "80_1_0_tcp", RuntimeName: name, DesiredState: "deleted", Applied: 0,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.h.retryPendingPeerShareOperations()
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
|
||||
t.Fatalf("pending delete was not retried: %+v", commands)
|
||||
}
|
||||
a.h.retryPendingPeerShareOperations()
|
||||
a.probe(t)
|
||||
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
|
||||
t.Fatalf("completed delete was replayed: %+v", commands)
|
||||
}
|
||||
ids, err := a.h.repo.ListPendingPeerShareNodeIDs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Fatalf("completed operation still pending: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowOwnershipDoesNotCrossNodes(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||
share, err := a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
share.MaxBandwidth = 100
|
||||
if err := a.h.repo.UpdatePeerShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Node 2 has no shared runtime. It reports the same local numeric service name.
|
||||
a.h.applyFlowUploadBatch(2, a.h.buildNodeFlowUploadBatch(2, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, nil), time.Now())
|
||||
a.probe(t)
|
||||
share, err = a.h.repo.GetPeerShare(shareID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands := a.commandsOfType("DeleteService")
|
||||
t.Logf("node1 share charged from node2: %d; node1 delete commands: %+v", share.CurrentFlow, commands)
|
||||
if share.CurrentFlow != 0 {
|
||||
t.Errorf("flow crossed node boundary: %d", share.CurrentFlow)
|
||||
}
|
||||
if len(commands) != 0 {
|
||||
t.Errorf("node2 flow deleted node1 shared service: %+v", commands)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFlowOwnershipPendingDeletionRemainsBillableUntilAcknowledged(t *testing.T) {
|
||||
for _, mode := range []string{"single", "batch"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
share := resourceTestShare(t, a.h, "pending-deletion-"+mode)
|
||||
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
|
||||
t.Fatal(got.Msg)
|
||||
}
|
||||
name := peerShareResourceName(share.ID, "service", "70_1_0_tcp")
|
||||
// Simulate a failed delivery after the deletion intent is committed. The
|
||||
// mock node's previously installed listener has received no delete command.
|
||||
disconnected := &Handler{repo: a.h.repo}
|
||||
if got := resourceTestCommand(t, disconnected, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code == 0 {
|
||||
t.Fatal("failed deletion reported success")
|
||||
}
|
||||
resource, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || resource == nil || resource.DesiredState != "deleted" || resource.Applied != 0 {
|
||||
t.Fatalf("missing pending tombstone: %+v %v", resource, err)
|
||||
}
|
||||
report := func() {
|
||||
item := flowItem{N: name, U: 120, D: 80}
|
||||
if mode == "single" {
|
||||
a.h.processFlowItem(1, item)
|
||||
} else {
|
||||
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{item}, nil), time.Now())
|
||||
}
|
||||
}
|
||||
report()
|
||||
stored, err := a.h.repo.GetPeerShare(share.ID)
|
||||
if err != nil || stored.CurrentFlow != 200 {
|
||||
t.Fatalf("unconfirmed deletion stopped billing: share=%+v err=%v", stored, err)
|
||||
}
|
||||
if len(a.commandsOfType("DeleteService")) != 0 {
|
||||
t.Fatal("flow handling deleted a pending shared listener")
|
||||
}
|
||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
|
||||
if err != nil || resource.Applied != 1 {
|
||||
t.Fatalf("deletion acknowledgment not recorded: %+v %v", resource, err)
|
||||
}
|
||||
report()
|
||||
stored, err = a.h.repo.GetPeerShare(share.ID)
|
||||
if err != nil || stored.CurrentFlow != 200 {
|
||||
t.Fatalf("confirmed-deleted listener was billed again: share=%+v err=%v", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,36 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const bytesPerGB int64 = 1024 * 1024 * 1024
|
||||
const bytesPerMiB int64 = 1024 * 1024
|
||||
|
||||
func flowLimitBytes(flowGB, flowMiB int64) int64 {
|
||||
if flowMiB > 0 {
|
||||
if flowMiB > math.MaxInt64/bytesPerMiB {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return flowMiB * bytesPerMiB
|
||||
}
|
||||
if flowGB > math.MaxInt64/bytesPerGB {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return flowGB * bytesPerGB
|
||||
}
|
||||
|
||||
type userTunnelPolicy struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
Flow int64
|
||||
FlowMiB int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
@@ -32,38 +47,22 @@ type gostConfigSnapshot struct {
|
||||
}
|
||||
|
||||
type namedConfigItem struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Limiter string `json:"limiter,omitempty"`
|
||||
Handler *struct {
|
||||
Chain string `json:"chain"`
|
||||
} `json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
if h.forwardExists(forwardID) {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
} else if nodeID > 0 {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
return
|
||||
metas, err := h.repo.GetFlowUploadForwardMetas(collectFlowUploadForwardIDs([]flowItem{item}))
|
||||
if err != nil {
|
||||
metas = nil
|
||||
}
|
||||
|
||||
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
h.applyFlowUploadBatch(nodeID, h.buildNodeFlowUploadBatch(nodeID, []flowItem{item}, metas), time.Now())
|
||||
}
|
||||
|
||||
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
@@ -138,73 +137,49 @@ func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
|
||||
return shareID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
func (h *Handler) processPeerShareFlow(nodeID, runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || runtimeID <= 0 {
|
||||
return
|
||||
}
|
||||
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
|
||||
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
|
||||
if err != nil || runtime == nil || runtime.NodeID != nodeID || runtime.Status != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
share, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
h.addPeerShareFlow(nodeID, runtime.ShareID, item.D+item.U)
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
// Prefer the reporting node's explicit shared ownership over a coincidentally
|
||||
// equal local forward ID. Never fall back to a service on another node.
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, runtime := range runtimes {
|
||||
if normalizeForwardRuntimeServiceName(runtime.ServiceName) == normalizeForwardRuntimeServiceName(serviceName) {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
}
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
// Forward not found in local database - might be a federation port-forward
|
||||
// Try to find by service name in peer_share_runtime
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
_, userID, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok || userID != forward.UserID {
|
||||
return
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||
if err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
if ok {
|
||||
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
|
||||
}
|
||||
|
||||
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
@@ -219,63 +194,26 @@ func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || strings.TrimSpace(serviceName) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
var runtimes []model.PeerShareRuntime
|
||||
var err error
|
||||
|
||||
// Try node-scoped query first if nodeID is valid
|
||||
if nodeID > 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
|
||||
if err != nil {
|
||||
var shareID int64
|
||||
for _, runtime := range runtimes {
|
||||
if normalizeForwardRuntimeServiceName(runtime.ServiceName) != normalizeForwardRuntimeServiceName(serviceName) {
|
||||
continue
|
||||
}
|
||||
if shareID != 0 {
|
||||
log.Printf("ambiguous peer share runtime service=%s node_id=%d", serviceName, nodeID)
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
shareID = runtime.ShareID
|
||||
}
|
||||
|
||||
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
|
||||
if len(runtimes) == 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(runtimes) != 1 {
|
||||
if len(runtimes) > 1 {
|
||||
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
|
||||
}
|
||||
return
|
||||
}
|
||||
runtime := runtimes[0]
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || matchedShare == nil {
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(matchedShare) {
|
||||
h.enforcePeerShareFlowLimit(matchedShare.ID)
|
||||
if shareID > 0 {
|
||||
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,22 +221,8 @@ func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
}
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
|
||||
if err != nil || len(runtimes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, runtime := range runtimes {
|
||||
if h.wsServer != nil && runtime.Applied == 1 {
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||
}
|
||||
}
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
if err := h.cleanupPeerShareRuntimes(shareID); err != nil {
|
||||
log.Printf("peer share quota cleanup pending share_id=%d err=%v", shareID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +282,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return errors.New("账号已过期")
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return errors.New("流量已超额,禁止开启转发")
|
||||
@@ -400,7 +324,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return errors.New("该隧道已过期")
|
||||
}
|
||||
|
||||
utFlowLimit := policy.Flow * bytesPerGB
|
||||
utFlowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
|
||||
utCurrent := policy.InFlow + policy.OutFlow
|
||||
if utCurrent >= utFlowLimit {
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
@@ -425,7 +349,7 @@ func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return true
|
||||
@@ -441,7 +365,7 @@ func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
flowLimit := policy.Flow * bytesPerGB
|
||||
flowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
|
||||
current := policy.InFlow + policy.OutFlow
|
||||
if current >= flowLimit {
|
||||
return true
|
||||
@@ -465,11 +389,53 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
||||
}
|
||||
return &userTunnelPolicy{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// disableUserAccess retains unconfirmed forwards as active so a repeated disable
|
||||
// can retry them. Login and tunnel access remain disabled when a node is offline.
|
||||
func (h *Handler) disableUserAccess(userID int64, now int64) error {
|
||||
permissionErr := h.repo.DisableUserTunnels(userID)
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return errors.Join(permissionErr, err)
|
||||
}
|
||||
errs := []error{permissionErr}
|
||||
for i := range forwards {
|
||||
forward := &forwards[i]
|
||||
nftMode, entryNodeIDs, pauseErr := h.tunnelUsesNftables(forward.TunnelID)
|
||||
if pauseErr == nil {
|
||||
if nftMode {
|
||||
// Exclude the rule from the desired plan before marking its pause
|
||||
// confirmed. A failed SSH apply must remain eligible for retry.
|
||||
pauseErr = h.syncNftablesNode(entryNodeIDs[0], forward.ID)
|
||||
if pauseErr != nil {
|
||||
pauseErr = h.nodeCommandError(entryNodeIDs[0], pauseErr)
|
||||
}
|
||||
} else {
|
||||
// Missing services are already stopped. Self-healing would recreate
|
||||
// services on every entry and could restart one already paused.
|
||||
pauseErr = h.controlForwardServicesWithOptions(forward, "PauseService", forwardServiceControlOptions{
|
||||
tolerateNotFound: true,
|
||||
tolerateOffline: false,
|
||||
allowSelfHeal: false,
|
||||
continueOnError: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if pauseErr != nil {
|
||||
errs = append(errs, fmt.Errorf("转发 %d 暂停失败: %w", forward.ID, pauseErr))
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (h *Handler) pauseUserForwards(userID int64, now int64) {
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
@@ -515,43 +481,102 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
return
|
||||
}
|
||||
|
||||
h.cleanOrphanedServices(nodeID, snapshot.Services)
|
||||
h.cleanOrphanedChains(nodeID, snapshot.Chains)
|
||||
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
|
||||
protection, err := h.loadForwardServiceProtection(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
|
||||
if err != nil {
|
||||
hasUnboundForwardPeerRuntime = false
|
||||
h.cleanOrphanedServicesWithProtection(nodeID, snapshot.Services, protection)
|
||||
// Dependencies are sent before services. A pending shared reservation may
|
||||
// therefore have chains/limiters that are not referenced in this snapshot yet.
|
||||
if protection.unbound {
|
||||
return
|
||||
}
|
||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||
for _, serviceName := range runtimeServiceNames {
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
chainsInUse := make(map[string]struct{})
|
||||
limitersInUse := make(map[string]struct{})
|
||||
for _, service := range snapshot.Services {
|
||||
if service.Handler != nil {
|
||||
if chain := strings.TrimSpace(service.Handler.Chain); chain != "" {
|
||||
chainsInUse[chain] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, limiter := range strings.Split(service.Limiter, ",") {
|
||||
if limiter = strings.TrimSpace(limiter); limiter != "" {
|
||||
limitersInUse[limiter] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep dependencies referenced by the reported services, even when their
|
||||
// IDs belong to a different panel. Orphan dependencies can be collected on
|
||||
// the next report after their services have actually disappeared.
|
||||
h.cleanOrphanedChains(nodeID, snapshot.Chains, chainsInUse)
|
||||
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters, limitersInUse)
|
||||
}
|
||||
|
||||
type forwardServiceProtection struct {
|
||||
sharedNames map[string]struct{}
|
||||
unbound bool
|
||||
}
|
||||
|
||||
// Shared forward IDs belong to another panel and need not exist in our forward
|
||||
// table. Use the same node-scoped ownership check for config and flow reports.
|
||||
func (h *Handler) loadForwardServiceProtection(nodeID int64) (forwardServiceProtection, error) {
|
||||
protection := forwardServiceProtection{sharedNames: make(map[string]struct{})}
|
||||
// Read names and pending bindings in one snapshot, so a concurrent bind
|
||||
// cannot fall between two queries and disappear from both protections.
|
||||
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
return protection, err
|
||||
}
|
||||
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||
for _, runtime := range runtimes {
|
||||
serviceName := normalizeForwardRuntimeServiceName(runtime.ServiceName)
|
||||
if serviceName == "" {
|
||||
if runtime.Applied == 0 && runtime.UpdatedTime >= minUpdatedTime {
|
||||
protection.unbound = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
runtimeServiceSet[serviceName] = struct{}{}
|
||||
protection.sharedNames[serviceName] = struct{}{}
|
||||
}
|
||||
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
|
||||
if err != nil {
|
||||
return protection, err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if base := normalizeForwardRuntimeServiceName(resource.LegacyServiceBase); base != "" {
|
||||
protection.sharedNames[base] = struct{}{}
|
||||
}
|
||||
}
|
||||
return protection, nil
|
||||
}
|
||||
|
||||
func (p forwardServiceProtection) preserves(serviceName string) bool {
|
||||
_, shared := p.sharedNames[normalizeForwardRuntimeServiceName(serviceName)]
|
||||
return shared || p.unbound
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
protection, err := h.loadForwardServiceProtection(nodeID)
|
||||
if err != nil {
|
||||
// A failed ownership lookup must never authorize deletion.
|
||||
return
|
||||
}
|
||||
h.cleanOrphanedServicesWithProtection(nodeID, services, protection)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServicesWithProtection(nodeID int64, services []namedConfigItem, protection forwardServiceProtection) {
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "fed_svc_") {
|
||||
if strings.HasPrefix(name, "fed_svc_") || strings.HasPrefix(name, "peer-share-") {
|
||||
continue
|
||||
}
|
||||
normalizedName := normalizeForwardRuntimeServiceName(name)
|
||||
if _, ok := runtimeServiceSet[normalizedName]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := runtimeServiceSet[name]; ok {
|
||||
if _, ok := protection.sharedNames[normalizeForwardRuntimeServiceName(name)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -564,15 +589,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); ok {
|
||||
h.deleteOrphanedForwardService(nodeID, name, protection)
|
||||
continue
|
||||
}
|
||||
suffix := parts[len(parts)-1]
|
||||
|
||||
@@ -591,23 +610,17 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
}
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(name, "_tcp")
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
|
||||
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem, inUse map[string]struct{}) {
|
||||
for _, item := range chains {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
if name == "" || strings.HasPrefix(name, "fed_chain_") || strings.HasPrefix(name, "peer-share-") {
|
||||
continue
|
||||
}
|
||||
if _, ok := inUse[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -616,17 +629,28 @@ func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
|
||||
continue
|
||||
}
|
||||
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
|
||||
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
|
||||
if err != nil || tunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
exists, err := h.repo.TunnelExists(tunnelID)
|
||||
if err != nil || exists {
|
||||
continue
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
|
||||
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem, inUse map[string]struct{}) {
|
||||
for _, item := range limiters {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || h.speedLimiterExists(name) {
|
||||
if name == "" || strings.HasPrefix(name, "peer-share-") {
|
||||
continue
|
||||
}
|
||||
if _, ok := inUse[name]; ok {
|
||||
continue
|
||||
}
|
||||
exists, err := h.lookupSpeedLimiter(name)
|
||||
if err != nil || exists {
|
||||
continue
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
|
||||
@@ -644,28 +668,78 @@ func (h *Handler) forwardExists(forwardID int64) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
|
||||
h.sendDeleteOrphanedForwardServices(nodeID, []string{serviceName})
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteOrphanedForwardServices(nodeID int64, serviceNames []string) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || len(serviceNames) == 0 {
|
||||
return
|
||||
}
|
||||
protection, err := h.loadForwardServiceProtection(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(serviceNames))
|
||||
for _, serviceName := range serviceNames {
|
||||
serviceName = normalizeForwardRuntimeServiceName(serviceName)
|
||||
if _, ok := seen[serviceName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[serviceName] = struct{}{}
|
||||
h.deleteOrphanedForwardService(nodeID, serviceName, protection)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) deleteOrphanedForwardService(nodeID int64, serviceName string, protection forwardServiceProtection) {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok || protection.preserves(serviceName) {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(serviceName, "_")
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
base := parts[0] + "_" + parts[1] + "_" + parts[2]
|
||||
// Parsing accepts legacy suffixes, while deletion targets the entire base
|
||||
// family. Verify the actual targets cannot include a protected share.
|
||||
if protection.preserves(base) {
|
||||
return
|
||||
}
|
||||
// Batch metadata can be missing after a read failure, or stale by the time
|
||||
// cleanup runs. Confirm absence before issuing a destructive command.
|
||||
exists, err := h.repo.ForwardExists(forwardID)
|
||||
if err != nil || exists {
|
||||
return
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
|
||||
"services": []string{base + "_tcp", base + "_udp"},
|
||||
"services": buildForwardServiceDeleteNames([]string{base}),
|
||||
}, false, true)
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
exists, _ := h.lookupSpeedLimiter(name)
|
||||
return exists
|
||||
}
|
||||
|
||||
func (h *Handler) lookupSpeedLimiter(name string) (bool, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
const forwardRulePrefix = "rule_traffic_limit_"
|
||||
if strings.HasPrefix(name, forwardRulePrefix) {
|
||||
forwardID, err := strconv.ParseInt(strings.TrimPrefix(name, forwardRulePrefix), 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if errors.Is(err, errForwardNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0, err
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(name, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
ok, _ := h.repo.SpeedLimitExists(id)
|
||||
return ok
|
||||
return h.repo.SpeedLimitExists(id)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
a := newCleanupAgent(t)
|
||||
r := a.h.repo
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
@@ -43,7 +40,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h := a.h
|
||||
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
@@ -120,6 +117,9 @@ func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
if err := r.DB().Exec("INSERT INTO forward_port(forward_id, node_id, port) VALUES(20, 1, 30001)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestSpeedLimiterExistsPreservesForwardRuleLimiter(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, ip_speed_id)
|
||||
VALUES(8, 1, 'user', 'forward', 1, '127.0.0.1:80', 'fifo', 0, 0, 1, 1, 1, 0, 3),
|
||||
(9, 1, 'user', 'forward-without-ip-limit', 1, '127.0.0.1:81', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
if !h.speedLimiterExists("rule_traffic_limit_8") {
|
||||
t.Fatal("expected runtime limiter for existing forward to be preserved")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_9") {
|
||||
t.Fatal("expected runtime limiter for forward without per-IP speed limit to be treated as orphaned")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_10") {
|
||||
t.Fatal("expected runtime limiter for missing forward to be treated as orphaned")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_invalid") {
|
||||
t.Fatal("expected malformed runtime limiter name to be treated as orphaned")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type flowUploadBatch struct {
|
||||
orphanServices map[string]struct{}
|
||||
peerShareForwardItems map[string]flowItem
|
||||
peerShareRuntimeItems map[int64]flowItem
|
||||
peerShareUsage map[int64]int64
|
||||
}
|
||||
|
||||
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
@@ -65,6 +66,9 @@ func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.Fl
|
||||
batch.orphanServices[serviceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
// Local accounting uses database ownership, not foreign IDs embedded in
|
||||
// a service name (including stale user-tunnel IDs after reassignment).
|
||||
userID, userTunnelID = meta.UserID, meta.UserTunnelID
|
||||
|
||||
raw := batch.forwardTraffic[forwardID]
|
||||
raw.bytesIn += item.D
|
||||
@@ -120,8 +124,12 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
for serviceName := range batch.orphanServices {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
if len(batch.orphanServices) > 0 {
|
||||
serviceNames := make([]string, 0, len(batch.orphanServices))
|
||||
for serviceName := range batch.orphanServices {
|
||||
serviceNames = append(serviceNames, serviceName)
|
||||
}
|
||||
h.sendDeleteOrphanedForwardServices(nodeID, serviceNames)
|
||||
}
|
||||
for serviceName, item := range batch.peerShareForwardItems {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
@@ -130,7 +138,10 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
|
||||
}
|
||||
}
|
||||
for runtimeID, item := range batch.peerShareRuntimeItems {
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
h.processPeerShareFlow(nodeID, runtimeID, item)
|
||||
}
|
||||
for shareID, delta := range batch.peerShareUsage {
|
||||
h.addPeerShareFlow(nodeID, shareID, delta)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t
|
||||
metas := map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {
|
||||
ForwardID: 20,
|
||||
UserID: 2,
|
||||
UserTunnelID: 10,
|
||||
TunnelID: 1,
|
||||
TrafficRatio: 2,
|
||||
TunnelFlow: 3,
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing.T) {
|
||||
func TestBuildForwardServiceConfigsAppliesProxyProtocolReceiveAndSendIndependently(t *testing.T) {
|
||||
forward := &forwardRecord{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
ProxyProtocol: 2,
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
ProxyProtocolReceive: 1,
|
||||
ProxyProtocolSend: 2,
|
||||
}
|
||||
tunnel := &tunnelRecord{Type: 1}
|
||||
node := &nodeRecord{
|
||||
@@ -38,8 +39,8 @@ func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing
|
||||
if serviceMetadata["interface"] != "eth0" {
|
||||
t.Fatalf("expected interface metadata eth0, got %v", serviceMetadata["interface"])
|
||||
}
|
||||
if _, ok := serviceMetadata["proxyProtocol"]; ok {
|
||||
t.Fatalf("proxyProtocol should not be listener metadata: %v", serviceMetadata)
|
||||
if serviceMetadata["proxyProtocol"] != 1 {
|
||||
t.Fatalf("expected service proxyProtocol 1 for receive mode, got %v", serviceMetadata["proxyProtocol"])
|
||||
}
|
||||
|
||||
handlerConfig, ok := service["handler"].(map[string]interface{})
|
||||
@@ -56,6 +57,46 @@ func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigsKeepsLegacyProxyProtocolAsSend(t *testing.T) {
|
||||
forward := &forwardRecord{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
ProxyProtocol: 2,
|
||||
}
|
||||
tunnel := &tunnelRecord{Type: 1}
|
||||
node := &nodeRecord{
|
||||
TCPListenAddr: "0.0.0.0",
|
||||
UDPListenAddr: "0.0.0.0",
|
||||
}
|
||||
|
||||
services := buildForwardServiceConfigs("1_2_3", forward, tunnel, node, 4001, "", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
serviceMetadata, _ := service["metadata"].(map[string]interface{})
|
||||
if _, ok := serviceMetadata["proxyProtocol"]; ok {
|
||||
t.Fatalf("legacy proxyProtocol should not enable receive mode: %v", serviceMetadata)
|
||||
}
|
||||
|
||||
handlerConfig, ok := service["handler"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler config map, got %T", service["handler"])
|
||||
}
|
||||
handlerMetadata, ok := handlerConfig["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler metadata map, got %T", handlerConfig["metadata"])
|
||||
}
|
||||
if handlerMetadata["proxyProtocol"] != 2 {
|
||||
t.Fatalf("expected legacy proxyProtocol to send version 2, got %v", handlerMetadata["proxyProtocol"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackForwardMutationRestoresProxyProtocol(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actorID int64
|
||||
actorRole int
|
||||
forwardID int64
|
||||
wantCode int
|
||||
wantInFlow int64
|
||||
wantOutFlow int64
|
||||
}{
|
||||
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
|
||||
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, r := setupForwardResetFlowHandler(t)
|
||||
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.forwardResetFlow(res, req)
|
||||
|
||||
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
|
||||
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
|
||||
}
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
|
||||
h, _ := setupForwardResetFlowHandler(t)
|
||||
|
||||
t.Run("non post", func(t *testing.T) {
|
||||
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardResetFlow(res, req)
|
||||
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||
t.Fatalf("code = %d, want -1", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid id", func(t *testing.T) {
|
||||
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardResetFlow(res, req)
|
||||
if code := decodeForwardResetFlowCode(t, res); code != -1 {
|
||||
t.Fatalf("code = %d, want -1", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
statements := []string{
|
||||
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
|
||||
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(3, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
|
||||
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
|
||||
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
|
||||
`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := r.DB().Exec(statement).Error; err != nil {
|
||||
t.Fatalf("seed database: %v", err)
|
||||
}
|
||||
}
|
||||
return New(r, "test-secret"), r
|
||||
}
|
||||
|
||||
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]int64{"id": forwardID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
|
||||
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
|
||||
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
|
||||
}
|
||||
|
||||
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
|
||||
}
|
||||
return payload.Code
|
||||
}
|
||||
|
||||
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("query %q: %v", query, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -20,9 +21,9 @@ import (
|
||||
"go-backend/internal/health"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/license"
|
||||
"go-backend/internal/metrics"
|
||||
"go-backend/internal/monitoring"
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
@@ -31,26 +32,33 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
metrics *metrics.IngestionService
|
||||
healthCheck *health.Checker
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
metrics *metrics.IngestionService
|
||||
healthCheck *health.Checker
|
||||
nftablesManager nftablesRuntimeManager
|
||||
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
peerResourceMu sync.Mutex
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
passkeyMu sync.Mutex
|
||||
passkeyPending map[string]passkeyCeremony
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
fingerprintMu sync.Mutex
|
||||
licenseValidationMu sync.Mutex
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
systemUpgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
nodeOnlineRedeployAt map[int64]time.Time
|
||||
nodeOnlineRedeployQueued map[int64]struct{}
|
||||
nodeOnlineRedeploying map[int64]struct{}
|
||||
upgradeMu sync.Mutex
|
||||
systemUpgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
nodeOnlineRedeployAt map[int64]time.Time
|
||||
nodeOnlineRedeployQueued map[int64]struct{}
|
||||
nodeOnlineRedeploying map[int64]struct{}
|
||||
nodeLocalRuntimeRetryQueued map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
bestExit *bestExitManager
|
||||
@@ -108,7 +116,9 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
metrics: metrics.NewIngestionService(repo),
|
||||
healthCheck: nil,
|
||||
nftablesManager: runtimenft.NewManager(nil),
|
||||
captchaTokens: make(map[string]int64),
|
||||
passkeyPending: make(map[string]passkeyCeremony),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
nodeOnlineRedeployAt: make(map[int64]time.Time),
|
||||
nodeOnlineRedeployQueued: make(map[int64]struct{}),
|
||||
@@ -150,6 +160,13 @@ func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/login", h.login)
|
||||
mux.HandleFunc("/api/v1/user/passkey/status", h.passkeyStatus)
|
||||
mux.HandleFunc("/api/v1/user/passkey/login/begin", h.passkeyLoginBegin)
|
||||
mux.HandleFunc("/api/v1/user/passkey/login/finish", h.passkeyLoginFinish)
|
||||
mux.HandleFunc("/api/v1/user/passkey/register/begin", h.passkeyRegisterBegin)
|
||||
mux.HandleFunc("/api/v1/user/passkey/register/finish", h.passkeyRegisterFinish)
|
||||
mux.HandleFunc("/api/v1/user/passkey/list", h.passkeyList)
|
||||
mux.HandleFunc("/api/v1/user/passkey/delete", h.passkeyDelete)
|
||||
mux.HandleFunc("/api/v1/user/list", h.userList)
|
||||
mux.HandleFunc("/api/v1/user/create", h.userCreate)
|
||||
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
||||
@@ -190,6 +207,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade)
|
||||
mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback)
|
||||
mux.HandleFunc("/api/v1/node/releases", h.listReleases)
|
||||
mux.HandleFunc("/api/v1/node/nftables/test", h.nodeNftablesTest)
|
||||
mux.HandleFunc("/api/v1/node/nftables/reconcile", h.nodeNftablesReconcile)
|
||||
mux.HandleFunc("/api/v1/node/nftables/clear", h.nodeNftablesClear)
|
||||
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
@@ -215,6 +235,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
|
||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
||||
@@ -393,6 +414,10 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if configName == "license_key" || configName == "license_machine_id" || configName == "machine_fingerprint" {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问系统授权凭据"))
|
||||
return
|
||||
}
|
||||
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
@@ -428,11 +453,13 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
||||
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "cloudflare_secret_key")
|
||||
delete(cfgMap, "jwt_secret")
|
||||
claims, isAdmin := ctxClaims.(auth.Claims)
|
||||
if !isAdmin || claims.RoleID != 0 {
|
||||
cfgMap = repo.FilterSensitiveConfigs(cfgMap)
|
||||
}
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "license_machine_id")
|
||||
delete(cfgMap, "machine_fingerprint")
|
||||
response.WriteJSON(w, response.OK(cfgMap))
|
||||
}
|
||||
|
||||
@@ -704,6 +731,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": t.Status,
|
||||
"flow": t.Flow,
|
||||
"flowMiB": t.FlowMiB,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
"flowResetTime": t.FlowResetTime,
|
||||
@@ -860,7 +888,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
|
||||
metas = map[int64]repo.FlowUploadForwardMeta{}
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(items, metas)
|
||||
batch := h.buildNodeFlowUploadBatch(node.ID, items, metas)
|
||||
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
|
||||
h.applyFlowUploadBatch(node.ID, batch, now)
|
||||
}
|
||||
@@ -871,10 +899,16 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
||||
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
h.fingerprintMu.Lock()
|
||||
defer h.fingerprintMu.Unlock()
|
||||
|
||||
fp, err := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
if fp != "" {
|
||||
return fp, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newFp := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
@@ -901,56 +935,35 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
h.licenseValidationMu.Lock()
|
||||
defer h.licenseValidationMu.Unlock()
|
||||
|
||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
||||
|
||||
fingerprint, err := h.getOrCreateMachineFingerprint()
|
||||
valResp, err := h.validateLicenseForMachine(key)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
|
||||
return
|
||||
}
|
||||
|
||||
client := license.NewKeygenClient(accountID, "")
|
||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
|
||||
log.Printf("license activation failed: %v", err)
|
||||
response.WriteJSON(w, response.ErrDefault(licenseValidationErrorMessage(err)))
|
||||
return
|
||||
}
|
||||
|
||||
if !valResp.Meta.Valid {
|
||||
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
|
||||
// Needs machine activation
|
||||
client.Token = key
|
||||
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
|
||||
if err != nil {
|
||||
// Translate specific error messages or log them
|
||||
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
|
||||
// we can consider the license valid for our simple usecase
|
||||
} else {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
|
||||
licenseState := map[string]string{
|
||||
"license_key": key,
|
||||
"is_commercial": "true",
|
||||
"license_expiry": expiry,
|
||||
}
|
||||
if valResp.MachineID != "" {
|
||||
licenseState["license_machine_id"] = valResp.MachineID
|
||||
}
|
||||
if err := h.repo.UpsertConfigs(licenseState, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -992,6 +1005,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if repo.IsSystemManagedConfigKey(key) {
|
||||
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||
return
|
||||
}
|
||||
|
||||
if protectedKeys[key] && isCommercial != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
@@ -1008,6 +1025,7 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.notifyTunnelQualityConfigChanged(key)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
@@ -1033,6 +1051,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if repo.IsSystemManagedConfigKey(name) {
|
||||
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||
return
|
||||
}
|
||||
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
||||
@@ -1055,6 +1077,7 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.notifyTunnelQualityConfigChanged(name)
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -1103,11 +1126,23 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
}
|
||||
case monitoring.ConfigMonitorRetentionDays:
|
||||
return monitoring.NormalizeMonitoringRetentionDays(value)
|
||||
case monitoring.ConfigTunnelQualityProbeIntervalSec:
|
||||
return monitoring.NormalizeTunnelQualityProbeIntervalSeconds(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) notifyTunnelQualityConfigChanged(key string) {
|
||||
if h == nil || h.qualityProber == nil {
|
||||
return
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case monitorTunnelQualityEnabledConfigKey, monitoring.ConfigTunnelQualityProbeIntervalSec:
|
||||
h.qualityProber.NotifyConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return true
|
||||
@@ -1191,6 +1226,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
"tunnelName": t.TunnelName,
|
||||
"tunnelFlow": t.TunnelFlow,
|
||||
"flow": t.Flow,
|
||||
"flowMiB": t.FlowMiB,
|
||||
"inFlow": t.InFlow,
|
||||
"outFlow": t.OutFlow,
|
||||
"num": t.Num,
|
||||
@@ -1240,6 +1276,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
"user": user.User,
|
||||
"status": user.Status,
|
||||
"flow": user.Flow,
|
||||
"flowMiB": user.FlowMiB,
|
||||
"inFlow": user.InFlow,
|
||||
"outFlow": user.OutFlow,
|
||||
"num": user.Num,
|
||||
|
||||
@@ -2,11 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/license"
|
||||
)
|
||||
|
||||
var nftablesTrafficCollectInterval = 30 * time.Second
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
@@ -20,7 +21,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(7)
|
||||
h.jobsWG.Add(9)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
@@ -30,10 +31,55 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
go h.runHealthChecks(ctx)
|
||||
go h.runTunnelQualityProber(ctx)
|
||||
go h.runValidateLicenseJob(ctx)
|
||||
go h.runNftablesTrafficCollectLoop(ctx)
|
||||
go h.runFederationCleanupRetryLoop(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
|
||||
log.Printf("federation cleanup remains pending: %v", err)
|
||||
}
|
||||
h.retryPendingPeerShareOperations()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) retryPendingPeerShareOperations() {
|
||||
nodeIDs, err := h.repo.ListPendingPeerShareNodeIDs()
|
||||
if err != nil {
|
||||
log.Printf("peer share pending operation lookup failed: %v", err)
|
||||
return
|
||||
}
|
||||
for _, nodeID := range nodeIDs {
|
||||
node, err := h.repo.GetNodeByID(nodeID)
|
||||
if err != nil || node == nil || node.Status != 1 {
|
||||
continue
|
||||
}
|
||||
if err := h.retryPendingPeerShareResourcesOnNode(nodeID); err != nil {
|
||||
log.Printf("peer share resource retry failed node_id=%d err=%v", nodeID, err)
|
||||
}
|
||||
if err := h.retryPendingPeerShareRoleRuntimesOnNode(nodeID); err != nil {
|
||||
log.Printf("peer share role retry failed node_id=%d err=%v", nodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
h.validateLicenseJob()
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -51,22 +97,25 @@ func (h *Handler) validateLicenseJob() {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
||||
h.licenseValidationMu.Lock()
|
||||
defer h.licenseValidationMu.Unlock()
|
||||
|
||||
key, _ := h.repo.GetViteConfigValue("license_key")
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
|
||||
if key == "" || isCommercial != "true" {
|
||||
if key == "" {
|
||||
return // Nothing to validate
|
||||
}
|
||||
|
||||
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
client := license.NewKeygenClient(accountID, "")
|
||||
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
|
||||
valResp, err := h.validateLicenseForMachine(key)
|
||||
|
||||
if err != nil {
|
||||
// Network error or timeout. Grace period by not revoking immediately here.
|
||||
// Network and decode failures have no validation response, so retain the
|
||||
// current state as a grace period. A rejected machine binding still has
|
||||
// the original invalid response and must not stay commercially enabled.
|
||||
if licenseValidationErrorIsDefinitive(valResp, err) {
|
||||
now := time.Now().UnixMilli()
|
||||
_ = h.repo.UpsertConfig("is_commercial", "false", now)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +129,14 @@ func (h *Handler) validateLicenseJob() {
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
|
||||
licenseState := map[string]string{
|
||||
"is_commercial": "true",
|
||||
"license_expiry": expiry,
|
||||
}
|
||||
if valResp.MachineID != "" {
|
||||
licenseState["license_machine_id"] = valResp.MachineID
|
||||
}
|
||||
_ = h.repo.UpsertConfigs(licenseState, now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +184,54 @@ func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runNftablesTrafficCollectLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
h.runNftablesStartupReconcile(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
h.runNftablesTrafficCollectJob(time.Now())
|
||||
}
|
||||
|
||||
interval := nftablesTrafficCollectInterval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.runNftablesTrafficCollectJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runNftablesStartupReconcile(ctx context.Context) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
nodes, err := h.repo.ListNftablesNodesForCollection()
|
||||
if err != nil {
|
||||
log.Printf("nftables startup reconcile failed op=list_nodes err=%v", err)
|
||||
return
|
||||
}
|
||||
for _, node := range nodes {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := h.syncNftablesNode(node.NodeID); err != nil {
|
||||
log.Printf("nftables startup reconcile failed node_id=%d err=%v", node.NodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/license"
|
||||
)
|
||||
|
||||
var newLicenseClient = license.NewKeygenClient
|
||||
|
||||
func keygenAccountID() string {
|
||||
if value := strings.TrimSpace(license.AccountID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("KEYGEN_ACCOUNT_ID"))
|
||||
}
|
||||
|
||||
func licenseNeedsMachineActivation(code string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func licenseValidationErrorIsDefinitive(validation *license.ValidateResponse, err error) bool {
|
||||
if err == nil {
|
||||
return validation != nil && !validation.Meta.Valid
|
||||
}
|
||||
var apiErr *license.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
if apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError {
|
||||
return false
|
||||
}
|
||||
return apiErr.Operation == "activate machine" && validation != nil && !validation.Meta.Valid
|
||||
}
|
||||
|
||||
func licenseValidationErrorMessage(err error) string {
|
||||
if strings.Contains(err.Error(), "keygen account id is not configured") {
|
||||
return "授权服务配置错误"
|
||||
}
|
||||
var apiErr *license.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.HasCode("MACHINE_LIMIT_EXCEEDED") {
|
||||
return "授权设备数量已达上限"
|
||||
}
|
||||
if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
|
||||
return "授权码无效或无权绑定设备"
|
||||
}
|
||||
}
|
||||
return "连接授权服务器失败,请稍后重试"
|
||||
}
|
||||
|
||||
func (h *Handler) validateLicenseForMachine(key string) (*license.ValidateResponse, error) {
|
||||
fingerprint, err := h.getOrCreateMachineFingerprint()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare machine fingerprint: %w", err)
|
||||
}
|
||||
storedMachineID, _ := h.repo.GetViteConfigValue("license_machine_id")
|
||||
|
||||
accountID := keygenAccountID()
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("keygen account id is not configured")
|
||||
}
|
||||
client := newLicenseClient(accountID, "")
|
||||
var validation *license.ValidateResponse
|
||||
if storedMachineID != "" {
|
||||
validation, err = client.ValidateKeyWithMachine(key, fingerprint, storedMachineID)
|
||||
} else {
|
||||
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
|
||||
if validation.Meta.Valid {
|
||||
validation.MachineID = storedMachineID
|
||||
}
|
||||
return validation, nil
|
||||
}
|
||||
|
||||
client.Token = key
|
||||
machineID, err := client.ActivateMachine(validation.Data.ID, fingerprint)
|
||||
if err != nil {
|
||||
return validation, err
|
||||
}
|
||||
if machineID == "" {
|
||||
machineID, err = client.GetMachineID(fingerprint)
|
||||
if err != nil {
|
||||
return validation, fmt.Errorf("retrieve activated machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err = client.ValidateKeyWithMachine(key, fingerprint, machineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validation.Meta.Valid {
|
||||
validation.MachineID = machineID
|
||||
}
|
||||
return validation, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/license"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
|
||||
var validations atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
if validations.Add(1) == 1 {
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||
fingerprint, err := r.GetViteConfigValue("machine_fingerprint")
|
||||
if err != nil || strings.TrimSpace(fingerprint) == "" {
|
||||
t.Fatalf("expected persisted machine fingerprint, got value=%q err=%v", fingerprint, err)
|
||||
}
|
||||
if got := validations.Load(); got != 2 {
|
||||
t.Fatalf("validation calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobAcceptsExistingMachineActivation(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
|
||||
var validations atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
if validations.Add(1) == 1 {
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_expiry", "never")
|
||||
if got := validations.Load(); got != 2 {
|
||||
t.Fatalf("validation calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
|
||||
var validations atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
code := "NO_MACHINE"
|
||||
if validations.Add(1) > 1 {
|
||||
code = "FINGERPRINT_SCOPE_MISMATCH"
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"meta":{"valid":false,"code":%q},"data":{"id":"license-id","attributes":{}}}`, code)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
|
||||
res := httptest.NewRecorder()
|
||||
h.licenseActivate(res, req)
|
||||
|
||||
if !strings.Contains(res.Body.String(), "FINGERPRINT_SCOPE_MISMATCH") {
|
||||
t.Fatalf("expected post-activation validation failure, got %s", res.Body.String())
|
||||
}
|
||||
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||
for _, name := range []string{"license_key", "license_expiry"} {
|
||||
if value, err := r.GetViteConfigValue(name); err == nil || value != "" {
|
||||
t.Fatalf("%s should not be persisted, got value=%q err=%v", name, value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivatePersistsValidatedState(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
|
||||
res := httptest.NewRecorder()
|
||||
h.licenseActivate(res, req)
|
||||
|
||||
if !strings.Contains(res.Body.String(), `"code":0`) {
|
||||
t.Fatalf("expected activation success, got %s", res.Body.String())
|
||||
}
|
||||
assertLicenseConfig(t, r, "license_key", "license-secret")
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobRestoresCommercialStateWhenLicenseRecovers(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "false", now)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_expiry", "never")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobUsesStoredMachineScope(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
seedLicenseConfig(t, r, "license_machine_id", "machine-id", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||
t.Fatalf("unexpected request %s %s", req.Method, req.URL.Path)
|
||||
}
|
||||
var body struct {
|
||||
Meta struct {
|
||||
Scope map[string]string `json:"scope"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Meta.Scope["machine"] != "machine-id" || body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||
t.Fatalf("unexpected validation scope: %+v", body.Meta.Scope)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_machine_id", "machine-id")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobKeepsStateOnMachineLookupServerFailure(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"SERVICE_UNAVAILABLE"}]}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobKeepsStateOnMachineLookupNotFound(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "true", now)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
http.NotFound(w, req)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
}
|
||||
|
||||
func TestLicenseValidationErrorMessageDoesNotExposeKeygenResponse(t *testing.T) {
|
||||
err := &license.APIError{
|
||||
Operation: "activate machine",
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED","detail":"private detail"}]}`,
|
||||
}
|
||||
message := licenseValidationErrorMessage(err)
|
||||
if message != "授权设备数量已达上限" || strings.Contains(message, "private detail") {
|
||||
t.Fatalf("unexpected public error message %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func openLicenseTestRepository(t *testing.T) *repo.Repository {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("repo.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
return r
|
||||
}
|
||||
|
||||
func seedLicenseConfig(t *testing.T, r *repo.Repository, name, value string, now int64) {
|
||||
t.Helper()
|
||||
if err := r.UpsertConfig(name, value, now); err != nil {
|
||||
t.Fatalf("UpsertConfig(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
|
||||
t.Helper()
|
||||
got, err := r.GetViteConfigValue(name)
|
||||
if err != nil {
|
||||
t.Fatalf("GetViteConfigValue(%q) error = %v", name, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("config %q = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
|
||||
t.Helper()
|
||||
t.Setenv("KEYGEN_ACCOUNT_ID", "account-id")
|
||||
previous := newLicenseClient
|
||||
newLicenseClient = func(accountID, token string) *license.KeygenClient {
|
||||
client := license.NewKeygenClient(accountID, token)
|
||||
client.BaseURL = baseURL
|
||||
return client
|
||||
}
|
||||
t.Cleanup(func() { newLicenseClient = previous })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type nftablesRuntimeManager interface {
|
||||
Test(ctx context.Context, cfg runtimenft.SSHConfig) error
|
||||
Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error)
|
||||
Clear(ctx context.Context, cfg runtimenft.SSHConfig) error
|
||||
CollectCounters(ctx context.Context, cfg runtimenft.SSHConfig) ([]runtimenft.CounterSample, error)
|
||||
}
|
||||
|
||||
func isNftablesForwardMode(mode string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(mode), runtimenft.ModeNftables)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeUsesNftables(nodeID int64) (bool, error) {
|
||||
return h.nodeUsesNftablesTx(nil, nodeID)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeUsesNftablesTx(tx *gorm.DB, nodeID int64) (bool, error) {
|
||||
if h == nil || h.repo == nil {
|
||||
return false, errors.New("handler not initialized")
|
||||
}
|
||||
var (
|
||||
mode string
|
||||
err error
|
||||
)
|
||||
if tx != nil {
|
||||
mode, err = h.repo.GetNodeForwardModeTx(tx, nodeID)
|
||||
} else {
|
||||
mode, err = h.repo.GetNodeForwardMode(nodeID)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isNftablesForwardMode(mode), nil
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelUsesNftables(tunnelID int64) (bool, []int64, error) {
|
||||
entryNodeIDs, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
for _, nodeID := range entryNodeIDs {
|
||||
ok, modeErr := h.nodeUsesNftables(nodeID)
|
||||
if modeErr != nil {
|
||||
return false, nil, modeErr
|
||||
}
|
||||
if ok {
|
||||
return true, entryNodeIDs, nil
|
||||
}
|
||||
}
|
||||
return false, entryNodeIDs, nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateNftablesForwardRequest(tunnel *tunnelRecord, remoteAddr string, entryNodeIDs []int64) error {
|
||||
if tunnel == nil {
|
||||
return errors.New("隧道不存在")
|
||||
}
|
||||
if tunnel.Type != 1 {
|
||||
return errors.New("nftables 节点仅支持直连隧道")
|
||||
}
|
||||
if len(entryNodeIDs) != 1 {
|
||||
return errors.New("nftables 节点仅支持单入口隧道")
|
||||
}
|
||||
target, err := runtimenft.ParseSingleTarget(remoteAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if net.ParseIP(strings.Trim(strings.TrimSpace(target.Host), "[]")) == nil {
|
||||
return errors.New("nftables 节点仅支持 IP 目标地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshConfigFromModel(cfg *model.NodeSSHConfig) (runtimenft.SSHConfig, error) {
|
||||
if cfg == nil {
|
||||
return runtimenft.SSHConfig{}, errors.New("节点缺少 SSH 配置")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.Username) == "" {
|
||||
return runtimenft.SSHConfig{}, errors.New("节点 SSH 配置不完整")
|
||||
}
|
||||
return runtimenft.SSHConfig{
|
||||
Host: strings.TrimSpace(cfg.Host),
|
||||
Port: cfg.Port,
|
||||
Username: strings.TrimSpace(cfg.Username),
|
||||
AuthType: strings.TrimSpace(cfg.AuthType),
|
||||
Password: cfg.Password.String,
|
||||
PrivateKey: cfg.PrivateKey.String,
|
||||
Passphrase: cfg.Passphrase.String,
|
||||
SudoMode: strings.TrimSpace(cfg.SudoMode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateNftablesTunnelState(entryNodeIDs []int64) error {
|
||||
return h.validateNftablesTunnelStateTx(nil, entryNodeIDs)
|
||||
}
|
||||
|
||||
func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("handler not initialized")
|
||||
}
|
||||
for _, nodeID := range entryNodeIDs {
|
||||
isNft, err := h.nodeUsesNftablesTx(tx, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isNft {
|
||||
continue
|
||||
}
|
||||
var cfg *model.NodeSSHConfig
|
||||
if tx != nil {
|
||||
cfg, err = h.repo.GetNodeSSHConfigTx(tx, nodeID)
|
||||
} else {
|
||||
cfg, err = h.repo.GetNodeSSHConfig(nodeID)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.New("nftables 节点缺少 SSH 配置")
|
||||
}
|
||||
return err
|
||||
}
|
||||
sshCfg, err := sshConfigFromModel(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.nftablesManager == nil {
|
||||
return errors.New("nftables manager not initialized")
|
||||
}
|
||||
if err := h.nftablesManager.Test(context.Background(), sshCfg); err != nil {
|
||||
return fmt.Errorf("nftables 节点能力校验失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
|
||||
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
|
||||
if err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
forwards, err := h.repo.ListActiveForwardsByNode(nodeID)
|
||||
if err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
|
||||
for i := range forwards {
|
||||
forward := &forwards[i]
|
||||
if slices.Contains(excludedForwardIDs, forward.ID) {
|
||||
continue
|
||||
}
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil || tunnel == nil || tunnel.Status != 1 {
|
||||
continue
|
||||
}
|
||||
entryNodeIDs, err := h.tunnelEntryNodeIDs(forward.TunnelID)
|
||||
if err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
if len(entryNodeIDs) != 1 || entryNodeIDs[0] != nodeID {
|
||||
continue
|
||||
}
|
||||
if err := h.validateNftablesForwardRequest(tunnel, forward.RemoteAddr, entryNodeIDs); err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
ports, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
for _, fp := range ports {
|
||||
if fp.NodeID != nodeID {
|
||||
continue
|
||||
}
|
||||
target, err := runtimenft.ParseSingleTarget(forward.RemoteAddr)
|
||||
if err != nil {
|
||||
return runtimenft.NodePlan{}, nil, err
|
||||
}
|
||||
plan.Rules = append(plan.Rules, runtimenft.Rule{
|
||||
ForwardID: forward.ID,
|
||||
InPort: fp.Port,
|
||||
BindIP: strings.TrimSpace(fp.InIP),
|
||||
TargetHost: target.Host,
|
||||
TargetPort: target.Port,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
})
|
||||
}
|
||||
}
|
||||
return plan, cfg, nil
|
||||
}
|
||||
|
||||
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("handler not initialized")
|
||||
}
|
||||
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshCfg, err := sshConfigFromModel(cfgModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := h.nftablesManager.Reconcile(context.Background(), sshCfg, plan)
|
||||
now := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
bindings, _ := h.repo.ListNftRuleBindingsByNode(nodeID)
|
||||
for _, binding := range bindings {
|
||||
_ = h.repo.MarkNftRuleBindingError(binding.ForwardID, nodeID, err.Error(), now)
|
||||
}
|
||||
return err
|
||||
}
|
||||
activeForwardIDs := make(map[int64]struct{}, len(plan.Rules))
|
||||
for _, rule := range plan.Rules {
|
||||
activeForwardIDs[rule.ForwardID] = struct{}{}
|
||||
hash := result.Hashes[rule.ForwardID]
|
||||
_ = h.repo.UpsertNftRuleBinding(modelToRuleBindingInput(nodeID, rule, hash), now)
|
||||
}
|
||||
bindings, _ := h.repo.ListNftRuleBindingsByNode(nodeID)
|
||||
for _, binding := range bindings {
|
||||
if _, ok := activeForwardIDs[binding.ForwardID]; ok {
|
||||
continue
|
||||
}
|
||||
_ = h.repo.DeleteNftRuleBindingsByForward(binding.ForwardID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modelToRuleBindingInput(nodeID int64, rule runtimenft.Rule, hash string) repo.NftRuleBindingInput {
|
||||
return repo.NftRuleBindingInput{
|
||||
ForwardID: rule.ForwardID,
|
||||
NodeID: nodeID,
|
||||
InPort: rule.InPort,
|
||||
Protocols: strings.Join(rule.Protocols, ","),
|
||||
TargetAddr: fmt.Sprintf("%s:%d", rule.TargetHost, rule.TargetPort),
|
||||
BindIP: rule.BindIP,
|
||||
RuleHash: hash,
|
||||
Status: runtimenft.StatusApplied,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) nftablesNodeIDFromRequest(r *http.Request, w http.ResponseWriter) (int64, bool) {
|
||||
nodeID := asInt64FromBodyKey(r, w, "nodeId")
|
||||
if nodeID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return nodeID, true
|
||||
}
|
||||
|
||||
func (h *Handler) loadNftablesSSHConfig(nodeID int64) (runtimenft.SSHConfig, error) {
|
||||
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return runtimenft.SSHConfig{}, errors.New("nftables 节点缺少 SSH 配置")
|
||||
}
|
||||
return runtimenft.SSHConfig{}, err
|
||||
}
|
||||
return sshConfigFromModel(cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) clearNftablesNode(nodeID int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("handler not initialized")
|
||||
}
|
||||
sshCfg, err := h.loadNftablesSSHConfig(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.nftablesManager == nil {
|
||||
return errors.New("nftables manager not initialized")
|
||||
}
|
||||
if err := h.nftablesManager.Clear(context.Background(), sshCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
bindings, listErr := h.repo.ListNftRuleBindingsByNode(nodeID)
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if err := h.repo.DeleteNftRuleBindingsByForward(binding.ForwardID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) reconcileNftablesNodeByRequest(nodeID int64) error {
|
||||
usesNft, err := h.nodeUsesNftables(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !usesNft {
|
||||
return errors.New("节点未启用 nftables 转发模式")
|
||||
}
|
||||
return h.syncNftablesNode(nodeID)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeNftablesTest(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
usesNft, err := h.nodeUsesNftables(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if !usesNft {
|
||||
response.WriteJSON(w, response.ErrDefault("节点未启用 nftables 转发模式"))
|
||||
return
|
||||
}
|
||||
sshCfg, err := h.loadNftablesSSHConfig(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if h.nftablesManager == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "nftables manager not initialized"))
|
||||
return
|
||||
}
|
||||
if err := h.nftablesManager.Test(context.Background(), sshCfg); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) nodeNftablesReconcile(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.reconcileNftablesNodeByRequest(nodeID); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) nodeNftablesClear(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
usesNft, err := h.nodeUsesNftables(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if !usesNft {
|
||||
response.WriteJSON(w, response.ErrDefault("节点未启用 nftables 转发模式"))
|
||||
return
|
||||
}
|
||||
if err := h.clearNftablesNode(nodeID); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type fakeNftablesManager struct {
|
||||
mu sync.Mutex
|
||||
testErr error
|
||||
reconcileErr error
|
||||
reconcileHit int
|
||||
clearErr error
|
||||
clearHit int
|
||||
collectErr error
|
||||
collectHit int
|
||||
counterSamples []runtimenft.CounterSample
|
||||
lastConfig runtimenft.SSHConfig
|
||||
lastPlan runtimenft.NodePlan
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) Test(_ context.Context, cfg runtimenft.SSHConfig) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastConfig = cfg
|
||||
return f.testErr
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) Reconcile(_ context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.reconcileHit++
|
||||
f.lastConfig = cfg
|
||||
f.lastPlan = plan
|
||||
if f.reconcileErr != nil {
|
||||
return runtimenft.ApplyResult{}, f.reconcileErr
|
||||
}
|
||||
return runtimenft.ApplyResult{
|
||||
NodeID: plan.NodeID,
|
||||
Script: "table inet flvx {}",
|
||||
Hashes: runtimenft.PlanHashes(plan),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) Clear(context.Context, runtimenft.SSHConfig) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.clearHit++
|
||||
return f.clearErr
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) CollectCounters(_ context.Context, cfg runtimenft.SSHConfig) ([]runtimenft.CounterSample, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.collectHit++
|
||||
f.lastConfig = cfg
|
||||
if f.collectErr != nil {
|
||||
return nil, f.collectErr
|
||||
}
|
||||
return f.counterSamples, nil
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) reconcileCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.reconcileHit
|
||||
}
|
||||
|
||||
func (f *fakeNftablesManager) collectCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.collectHit
|
||||
}
|
||||
|
||||
type nftablesTestFixture struct {
|
||||
handler *Handler
|
||||
nodeID int64
|
||||
}
|
||||
|
||||
func TestTunnelCreateRejectsNftablesEntryNodeWithoutSSHConfig(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
err := fixture.handler.validateNftablesTunnelState([]int64{fixture.nodeID})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SSH") {
|
||||
t.Fatalf("expected SSH config validation error, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateRejectsNftablesEntryNodeWhenCapabilityTestFails(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{testErr: errors.New("ssh failed")}
|
||||
h.nftablesManager = manager
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
err := h.validateNftablesTunnelState([]int64{fixture.nodeID})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ssh failed") {
|
||||
t.Fatalf("expected capability error in response, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncForwardServicesWithWarningsUsesNftablesRuntime(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
|
||||
warnings, err := h.syncForwardServicesWithWarnings(forward, "UpdateService", true)
|
||||
if err != nil {
|
||||
t.Fatalf("sync forward services: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got %v", warnings)
|
||||
}
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected nftables reconcile to run once, got %d", manager.reconcileHit)
|
||||
}
|
||||
if manager.lastPlan.NodeID != fixture.nodeID {
|
||||
t.Fatalf("expected plan for node %d, got %+v", fixture.nodeID, manager.lastPlan)
|
||||
}
|
||||
if len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != forward.ID {
|
||||
t.Fatalf("unexpected plan: %+v", manager.lastPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeNftablesTestEndpointRunsCapabilityCheck(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
|
||||
manager := &fakeNftablesManager{}
|
||||
fixture.handler.nftablesManager = manager
|
||||
|
||||
res := postJSONToHandler(t, fixture.handler.nodeNftablesTest, map[string]int64{"nodeId": fixture.nodeID})
|
||||
assertNftablesSuccess(t, res)
|
||||
if manager.lastConfig.Host != "203.0.113.10" {
|
||||
t.Fatalf("expected SSH config to be passed to manager, got %+v", manager.lastConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeNftablesReconcileEndpointPersistsBindings(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, fixture.handler, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, fixture.handler, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
fixture.handler.nftablesManager = manager
|
||||
|
||||
res := postJSONToHandler(t, fixture.handler.nodeNftablesReconcile, map[string]int64{"nodeId": fixture.nodeID})
|
||||
assertNftablesSuccess(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
bindings, err := fixture.handler.repo.ListNftRuleBindingsByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("list bindings: %v", err)
|
||||
}
|
||||
if len(bindings) != 1 || bindings[0].ForwardID != forward.ID {
|
||||
t.Fatalf("unexpected bindings: %+v", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartBackgroundJobsReconcilesNftablesRulesAtStartup(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-startup-tunnel", fixture.nodeID)
|
||||
seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
h.StartBackgroundJobs()
|
||||
t.Cleanup(h.StopBackgroundJobs)
|
||||
|
||||
waitForCondition(t, time.Second, func() bool {
|
||||
return manager.reconcileCount() > 0
|
||||
}, "nftables startup reconcile")
|
||||
}
|
||||
|
||||
func TestStartBackgroundJobsCollectsNftablesTrafficImmediatelyAndUsesFastInterval(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
}}
|
||||
h.nftablesManager = manager
|
||||
|
||||
oldInterval := nftablesTrafficCollectInterval
|
||||
nftablesTrafficCollectInterval = 20 * time.Millisecond
|
||||
t.Cleanup(func() { nftablesTrafficCollectInterval = oldInterval })
|
||||
|
||||
h.StartBackgroundJobs()
|
||||
t.Cleanup(h.StopBackgroundJobs)
|
||||
|
||||
waitForCondition(t, time.Second, func() bool {
|
||||
return manager.collectCount() >= 2
|
||||
}, "immediate and repeated nftables traffic collection")
|
||||
}
|
||||
|
||||
func TestNftablesTrafficCollectIntervalDefaultsToThirtySeconds(t *testing.T) {
|
||||
if nftablesTrafficCollectInterval != 30*time.Second {
|
||||
t.Fatalf("expected default nftables traffic collection interval 30s, got %s", nftablesTrafficCollectInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeNftablesClearEndpointClearsBindings(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := fixture.handler.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
|
||||
ForwardID: 99,
|
||||
NodeID: fixture.nodeID,
|
||||
InPort: 24000,
|
||||
Protocols: "tcp",
|
||||
TargetAddr: "203.0.113.9:8080",
|
||||
Status: runtimenft.StatusApplied,
|
||||
}, now); err != nil {
|
||||
t.Fatalf("seed binding: %v", err)
|
||||
}
|
||||
manager := &fakeNftablesManager{}
|
||||
fixture.handler.nftablesManager = manager
|
||||
|
||||
res := postJSONToHandler(t, fixture.handler.nodeNftablesClear, map[string]int64{"nodeId": fixture.nodeID})
|
||||
assertNftablesSuccess(t, res)
|
||||
if manager.clearHit != 1 {
|
||||
t.Fatalf("expected clear once, got %d", manager.clearHit)
|
||||
}
|
||||
if bindings, err := fixture.handler.repo.ListNftRuleBindingsByNode(fixture.nodeID); err != nil {
|
||||
t.Fatalf("list bindings after clear: %v", err)
|
||||
} else if len(bindings) != 0 {
|
||||
t.Fatalf("expected bindings to be cleared, got %+v", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCreatePersistsNftablesSSHConfig(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
|
||||
"name": "nft-node-created",
|
||||
"serverIp": "203.0.113.20",
|
||||
"serverIpV4": "203.0.113.20",
|
||||
"port": "20000-20100",
|
||||
"forwardMode": "nftables",
|
||||
"sshConfig": map[string]interface{}{
|
||||
"host": "203.0.113.21",
|
||||
"port": 2222,
|
||||
"username": "root",
|
||||
"authType": "private_key",
|
||||
"privateKey": "TEST-PRIVATE-KEY",
|
||||
"passphrase": "secret",
|
||||
"sudoMode": "sudo",
|
||||
},
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
fixture.handler.nodeCreate(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
|
||||
nodes, err := fixture.handler.repo.ListNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("list nodes: %v", err)
|
||||
}
|
||||
var createdNodeID int64
|
||||
for _, item := range nodes {
|
||||
if item["name"] == "nft-node-created" {
|
||||
createdNodeID = item["id"].(int64)
|
||||
break
|
||||
}
|
||||
}
|
||||
if createdNodeID <= 0 {
|
||||
t.Fatalf("expected created node to exist")
|
||||
}
|
||||
createdNode, err := fixture.handler.repo.GetNodeRecord(createdNodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load created node: %v", err)
|
||||
}
|
||||
if createdNode == nil {
|
||||
t.Fatal("expected created node record, got nil")
|
||||
}
|
||||
if createdNode.Status != 1 {
|
||||
t.Fatalf("expected nftables node to be online, got status %d", createdNode.Status)
|
||||
}
|
||||
cfg, err := fixture.handler.repo.GetNodeSSHConfig(createdNodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load ssh config: %v", err)
|
||||
}
|
||||
if cfg.Host != "203.0.113.21" || cfg.Port != 2222 || cfg.Username != "root" || cfg.AuthType != "private_key" {
|
||||
t.Fatalf("unexpected ssh config: %+v", cfg)
|
||||
}
|
||||
if !cfg.PrivateKey.Valid || cfg.PrivateKey.String != "TEST-PRIVATE-KEY" {
|
||||
t.Fatalf("expected private key to persist, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeUpdatePreservesExistingNftablesSecretsWhenFieldsOmitted(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
|
||||
"id": fixture.nodeID,
|
||||
"name": "nft-node-updated",
|
||||
"serverIp": "198.51.100.10",
|
||||
"serverIpV4": "198.51.100.10",
|
||||
"port": "1000-65535",
|
||||
"forwardMode": "nftables",
|
||||
"sshConfig": map[string]interface{}{
|
||||
"host": "203.0.113.30",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"sudoMode": "none",
|
||||
},
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
fixture.handler.nodeUpdate(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
|
||||
cfg, err := fixture.handler.repo.GetNodeSSHConfig(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load ssh config: %v", err)
|
||||
}
|
||||
if cfg.Host != "203.0.113.30" || cfg.Username != "admin" || cfg.AuthType != "password" {
|
||||
t.Fatalf("unexpected ssh config after update: %+v", cfg)
|
||||
}
|
||||
if !cfg.Password.Valid || cfg.Password.String != "secret" {
|
||||
t.Fatalf("expected password secret to be preserved, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeUpdateSkipsAgentProtocolCommandForNftablesNode(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
|
||||
"id": fixture.nodeID,
|
||||
"name": "nft-node-updated",
|
||||
"serverIp": "198.51.100.10",
|
||||
"serverIpV4": "198.51.100.10",
|
||||
"port": "1000-65535",
|
||||
"forwardMode": "nftables",
|
||||
"http": 1,
|
||||
"tls": 1,
|
||||
"socks": 1,
|
||||
"sshConfig": map[string]interface{}{
|
||||
"host": "203.0.113.30",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"sudoMode": "none",
|
||||
},
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
fixture.handler.nodeUpdate(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
|
||||
cfg, err := fixture.handler.repo.GetNodeSSHConfig(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load ssh config: %v", err)
|
||||
}
|
||||
if !cfg.Password.Valid || cfg.Password.String != "secret" {
|
||||
t.Fatalf("expected password secret to be preserved, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNftablesForwardRequestRejectsHostnameTarget(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||
if err != nil {
|
||||
t.Fatalf("load tunnel: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateNftablesForwardRequest(tunnel, "example.com:443", []int64{fixture.nodeID})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hostname target to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "IP") {
|
||||
t.Fatalf("expected IP literal validation error, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardDeleteReconcilesNftablesAfterDBDelete(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string]int64{"id": forward.ID})
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardDelete(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
if len(manager.lastPlan.Rules) != 0 {
|
||||
t.Fatalf("expected reconcile after DB delete to render no rules, got %+v", manager.lastPlan.Rules)
|
||||
}
|
||||
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
|
||||
t.Fatalf("expected forward to be deleted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardForceDeleteRemovesNftablesBindingAndReconciles(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
|
||||
ForwardID: forward.ID,
|
||||
NodeID: fixture.nodeID,
|
||||
InPort: 20000,
|
||||
Protocols: "tcp,udp",
|
||||
TargetAddr: "203.0.113.9:8080",
|
||||
Status: runtimenft.StatusApplied,
|
||||
}, time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("seed binding: %v", err)
|
||||
}
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string]int64{"id": forward.ID})
|
||||
req.URL.Path = "/api/v1/forward/force-delete"
|
||||
res := httptest.NewRecorder()
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
mux.ServeHTTP(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
|
||||
t.Fatalf("expected forward to be deleted, got %v", err)
|
||||
}
|
||||
if bindings, err := h.repo.ListNftRuleBindingsByNode(fixture.nodeID); err != nil {
|
||||
t.Fatalf("list bindings after delete: %v", err)
|
||||
} else if len(bindings) != 0 {
|
||||
t.Fatalf("expected no bindings after delete, got %+v", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardBatchDeleteReconcilesNftablesAfterDBDelete(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {forward.ID}})
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardBatchDelete(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
if len(manager.lastPlan.Rules) != 0 {
|
||||
t.Fatalf("expected reconcile after DB delete to render no rules, got %+v", manager.lastPlan.Rules)
|
||||
}
|
||||
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
|
||||
t.Fatalf("expected forward to be deleted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardBatchRedeployUsesNftablesReconcile(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {forward.ID}})
|
||||
res := httptest.NewRecorder()
|
||||
h.forwardBatchRedeploy(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchRedeployUsesNftablesReconcile(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
|
||||
seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
|
||||
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {tunnelID}})
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelBatchRedeploy(res, req)
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
if manager.reconcileHit != 1 {
|
||||
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseForwardRuntimeReturnsNftablesRuleStatus(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-diagnose-tunnel", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
|
||||
ForwardID: forward.ID,
|
||||
NodeID: fixture.nodeID,
|
||||
InPort: 20000,
|
||||
Protocols: "tcp,udp",
|
||||
TargetAddr: "203.0.113.9:8080",
|
||||
RuleHash: "hash-a",
|
||||
Status: runtimenft.StatusApplied,
|
||||
}, now); err != nil {
|
||||
t.Fatalf("seed nft binding: %v", err)
|
||||
}
|
||||
|
||||
payload, err := h.diagnoseForwardRuntime(context.Background(), &forwardRecord{
|
||||
ID: forward.ID,
|
||||
Name: forward.Name,
|
||||
TunnelID: tunnelID,
|
||||
RemoteAddr: "203.0.113.9:8080",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("diagnose forward: %v", err)
|
||||
}
|
||||
results, ok := payload["results"].([]map[string]interface{})
|
||||
if !ok || len(results) != 1 {
|
||||
t.Fatalf("expected one nftables diagnosis result, got %#v", payload["results"])
|
||||
}
|
||||
result := results[0]
|
||||
if result["forwardMode"] != "nftables" || result["nftRuleStatus"] != runtimenft.StatusApplied {
|
||||
t.Fatalf("expected nftables applied result, got %#v", result)
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected nftables diagnosis success, got %#v", result)
|
||||
}
|
||||
if !strings.Contains(asString(result["message"]), "已下发") {
|
||||
t.Fatalf("expected applied message, got %#v", result["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool, description string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
}
|
||||
|
||||
func setupNftablesHandler(t *testing.T) nftablesTestFixture {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "handler-nftables.sqlite")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
|
||||
h := New(r, "test-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := r.CreateUser("admin", "hash", 0, now+86400000, 1, 1, 100, 1, 0, now); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := r.CreateNode("nft-node", "secret", "198.51.100.10", nil, nil, "1000-65535", nil, nil, nil, nil, nil, 0, 0, 0, now, 1, "", "", 1, 0, nil, nil, nil, nil, "nftables"); err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
node, err := r.GetNodeRecord(1)
|
||||
if err != nil || node == nil {
|
||||
t.Fatalf("get node: %v", err)
|
||||
}
|
||||
return nftablesTestFixture{handler: h, nodeID: node.ID}
|
||||
}
|
||||
|
||||
func seedNftablesSSHConfig(t *testing.T, h *Handler, nodeID int64) {
|
||||
t.Helper()
|
||||
if err := h.repo.UpsertNodeSSHConfig(nodeID, repo.NftSSHConfigInput{
|
||||
Host: "203.0.113.10",
|
||||
Port: 22,
|
||||
Username: "root",
|
||||
AuthType: "password",
|
||||
Password: "secret",
|
||||
SudoMode: "none",
|
||||
}, time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("upsert ssh config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTunnelForNftables(t *testing.T, h *Handler, name string, nodeID int64) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
tx := h.repo.BeginTx()
|
||||
if tx == nil {
|
||||
t.Fatal("begin tx: nil transaction")
|
||||
}
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", tx.Error)
|
||||
}
|
||||
tunnelID, err := h.repo.CreateTunnelTx(tx, name, 1, 1, 1, now, 1, nil, 1, "", "", 0)
|
||||
if err != nil {
|
||||
_ = tx.Rollback().Error
|
||||
t.Fatalf("create tunnel: %v", err)
|
||||
}
|
||||
if err := h.repo.CreateChainTunnelTx(tx, tunnelID, "1", nodeID, sql.NullInt64{}, "", 1, "tls", ""); err != nil {
|
||||
_ = tx.Rollback().Error
|
||||
t.Fatalf("create chain tunnel: %v", err)
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
_ = tx.Rollback().Error
|
||||
t.Fatalf("commit tx: %v", err)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
func seedForwardForNftables(t *testing.T, h *Handler, tunnelID, nodeID int64, remoteAddr string) *forwardRecord {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := h.repo.CreateForwardTx(
|
||||
1, "admin", "nft-forward", tunnelID, remoteAddr, "fifo", now, 1,
|
||||
[]int64{nodeID}, 20000, "", nil, 0, 0, nil, 0, 0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("get forward: %v", err)
|
||||
}
|
||||
return forward
|
||||
}
|
||||
|
||||
func postJSONToHandler(t *testing.T, fn func(http.ResponseWriter, *http.Request), payload any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
res := httptest.NewRecorder()
|
||||
fn(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
func newAuthenticatedJSONRequest(t *testing.T, payload any) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
token, err := auth.GenerateToken(1, "admin", 0, "test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
claims, ok := auth.ValidateToken(token, "test-secret")
|
||||
if !ok {
|
||||
t.Fatalf("validate token failed")
|
||||
}
|
||||
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
|
||||
}
|
||||
|
||||
func assertNftablesSuccess(t *testing.T, res *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
assertNftablesSuccessWithBody(t, res)
|
||||
}
|
||||
|
||||
func assertNftablesSuccessWithBody(t *testing.T, res *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success, got %+v", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type nftTrafficDelta struct {
|
||||
ForwardID int64
|
||||
BytesIn int64
|
||||
BytesOut int64
|
||||
}
|
||||
|
||||
type nftCounterStateKey struct {
|
||||
forwardID int64
|
||||
protocol string
|
||||
direction string
|
||||
}
|
||||
|
||||
func (h *Handler) runNftablesTrafficCollectJob(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
nodes, err := h.repo.ListNftablesNodesForCollection()
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=list_nodes err=%v", err)
|
||||
return
|
||||
}
|
||||
for i := range nodes {
|
||||
node := &nodes[i]
|
||||
h.collectNftablesNodeTraffic(node.NodeID, &node.Config, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) collectNftablesNodeTraffic(nodeID int64, cfgModel *model.NodeSSHConfig, now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if h.nftablesManager == nil {
|
||||
log.Printf("nftables traffic collection failed op=collect node_id=%d err=%v", nodeID, "nftables manager not initialized")
|
||||
return
|
||||
}
|
||||
sshCfg, err := sshConfigFromModel(cfgModel)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=ssh_config node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
samples, err := h.nftablesManager.CollectCounters(context.Background(), sshCfg)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=collect node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
oldStates, err := h.repo.GetNftCounterStatesByNode(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=list_states node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
bindings, err := h.repo.ListNftRuleBindingsByNode(nodeID)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=list_bindings node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
hashes := make(map[int64]string, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
if strings.ToLower(strings.TrimSpace(binding.Status)) != runtimenft.StatusApplied {
|
||||
continue
|
||||
}
|
||||
ruleHash := strings.TrimSpace(binding.RuleHash)
|
||||
if ruleHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes[binding.ForwardID] = ruleHash
|
||||
}
|
||||
|
||||
nowMs := now.UnixMilli()
|
||||
boundSamples := filterNftCounterSamplesWithBinding(samples, hashes)
|
||||
deltas, newStates := buildNftCounterDeltas(nodeID, boundSamples, oldStates, hashes, nowMs)
|
||||
if len(newStates) == 0 {
|
||||
if len(deltas) != 0 {
|
||||
log.Printf("nftables traffic collection skipped suspicious deltas without states node_id=%d deltas=%d", nodeID, len(deltas))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var metas map[int64]repo.FlowUploadForwardMeta
|
||||
forwardIDs := make([]int64, 0, len(deltas))
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID > 0 {
|
||||
forwardIDs = append(forwardIDs, delta.ForwardID)
|
||||
}
|
||||
}
|
||||
if len(deltas) != 0 {
|
||||
metas, err = h.repo.GetFlowUploadForwardMetas(forwardIDs)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=load_flow_metas node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
if missingForwardID, ok := firstNftDeltaMissingMeta(deltas, metas); ok {
|
||||
log.Printf("nftables traffic collection skipped state advance op=missing_flow_meta node_id=%d forward_id=%d", nodeID, missingForwardID)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
if err := h.repo.UpsertNftCounterStates(newStates, nowMs); err != nil {
|
||||
log.Printf("nftables traffic collection failed op=upsert_states node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
batch := buildNftFlowUploadBatch(deltas, metas)
|
||||
if missingForwardID, ok := firstNftBatchMissingDelta(deltas, batch); ok {
|
||||
log.Printf("nftables traffic collection skipped state advance op=unaccounted_delta node_id=%d forward_id=%d", nodeID, missingForwardID)
|
||||
return
|
||||
}
|
||||
quotaViews, err := h.repo.ApplyNftTrafficAccounting(batch.flowDeltas, batch.quotaUsage, newStates, now)
|
||||
if err != nil {
|
||||
log.Printf("nftables traffic collection failed op=accounting node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
h.recordTunnelMetricsFromForwardBatch(nodeID, batch.forwardTraffic, metas, nowMs)
|
||||
for userID, quota := range quotaViews {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
for _, target := range batch.policyTargets {
|
||||
if target.UserID <= 0 || target.UserTunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNftBatchMissingDelta(deltas []nftTrafficDelta, batch flowUploadBatch) (int64, bool) {
|
||||
flowSeen := make(map[int64]struct{}, len(batch.flowDeltas))
|
||||
for _, delta := range batch.flowDeltas {
|
||||
flowSeen[delta.ForwardID] = struct{}{}
|
||||
}
|
||||
|
||||
expectedRaw := make(map[int64]tunnelTrafficDelta, len(batch.forwardTraffic))
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID <= 0 || (delta.BytesIn == 0 && delta.BytesOut == 0) {
|
||||
continue
|
||||
}
|
||||
if delta.BytesIn < 0 || delta.BytesOut < 0 {
|
||||
return delta.ForwardID, true
|
||||
}
|
||||
raw := expectedRaw[delta.ForwardID]
|
||||
if raw.bytesIn > math.MaxInt64-delta.BytesIn || raw.bytesOut > math.MaxInt64-delta.BytesOut {
|
||||
return delta.ForwardID, true
|
||||
}
|
||||
raw.bytesIn += delta.BytesIn
|
||||
raw.bytesOut += delta.BytesOut
|
||||
expectedRaw[delta.ForwardID] = raw
|
||||
}
|
||||
|
||||
for forwardID, expected := range expectedRaw {
|
||||
actual, ok := batch.forwardTraffic[forwardID]
|
||||
if !ok || actual.bytesIn != expected.bytesIn || actual.bytesOut != expected.bytesOut {
|
||||
return forwardID, true
|
||||
}
|
||||
if expected.bytesIn != 0 || expected.bytesOut != 0 {
|
||||
if _, ok := flowSeen[forwardID]; !ok {
|
||||
return forwardID, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstNftDeltaMissingMeta(deltas []nftTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta) (int64, bool) {
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := metas[delta.ForwardID]; !ok {
|
||||
return delta.ForwardID, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func filterNftCounterSamplesWithBinding(samples []runtimenft.CounterSample, hashes map[int64]string) []runtimenft.CounterSample {
|
||||
if len(samples) == 0 || len(hashes) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]runtimenft.CounterSample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
if _, ok := hashes[sample.ForwardID]; !ok {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, sample)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func nftCounterKey(forwardID int64, protocol, direction string) nftCounterStateKey {
|
||||
return nftCounterStateKey{
|
||||
forwardID: forwardID,
|
||||
protocol: strings.ToLower(strings.TrimSpace(protocol)),
|
||||
direction: strings.ToLower(strings.TrimSpace(direction)),
|
||||
}
|
||||
}
|
||||
|
||||
func buildNftCounterDeltas(nodeID int64, samples []runtimenft.CounterSample, oldStates []model.NftCounterState, hashes map[int64]string, nowMs int64) ([]nftTrafficDelta, []repo.NftCounterStateInput) {
|
||||
oldByKey := make(map[nftCounterStateKey]model.NftCounterState, len(oldStates))
|
||||
for _, old := range oldStates {
|
||||
if old.NodeID != nodeID {
|
||||
continue
|
||||
}
|
||||
oldByKey[nftCounterKey(old.ForwardID, old.Protocol, old.Direction)] = old
|
||||
}
|
||||
|
||||
stateInputs := make([]repo.NftCounterStateInput, 0, len(samples))
|
||||
deltaByForward := make(map[int64]nftTrafficDelta)
|
||||
for _, sample := range samples {
|
||||
direction := strings.ToLower(strings.TrimSpace(sample.Direction))
|
||||
if direction != runtimenft.CounterDirectionToTarget && direction != runtimenft.CounterDirectionFromTarget {
|
||||
continue
|
||||
}
|
||||
|
||||
protocol := strings.ToLower(strings.TrimSpace(sample.Protocol))
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
continue
|
||||
}
|
||||
if sample.Bytes > uint64(math.MaxInt64) || sample.Packets > uint64(math.MaxInt64) {
|
||||
continue
|
||||
}
|
||||
ruleHash := strings.TrimSpace(hashes[sample.ForwardID])
|
||||
stateInput := repo.NftCounterStateInput{
|
||||
NodeID: nodeID,
|
||||
ForwardID: sample.ForwardID,
|
||||
Protocol: protocol,
|
||||
Direction: direction,
|
||||
RuleHash: ruleHash,
|
||||
Bytes: sample.Bytes,
|
||||
Packets: sample.Packets,
|
||||
CollectedTime: nowMs,
|
||||
}
|
||||
|
||||
old, exists := oldByKey[nftCounterKey(sample.ForwardID, protocol, direction)]
|
||||
if !exists || old.RuleHash != ruleHash {
|
||||
stateInputs = append(stateInputs, stateInput)
|
||||
continue
|
||||
}
|
||||
if old.Bytes < 0 {
|
||||
stateInputs = append(stateInputs, stateInput)
|
||||
continue
|
||||
}
|
||||
oldBytes := uint64(old.Bytes)
|
||||
if sample.Bytes < oldBytes {
|
||||
stateInputs = append(stateInputs, stateInput)
|
||||
continue
|
||||
}
|
||||
rawDelta := sample.Bytes - oldBytes
|
||||
if rawDelta == 0 {
|
||||
stateInputs = append(stateInputs, stateInput)
|
||||
continue
|
||||
}
|
||||
|
||||
delta := deltaByForward[sample.ForwardID]
|
||||
delta.ForwardID = sample.ForwardID
|
||||
rawDeltaInt := int64(rawDelta)
|
||||
if direction == runtimenft.CounterDirectionToTarget {
|
||||
if delta.BytesIn > math.MaxInt64-rawDeltaInt {
|
||||
continue
|
||||
}
|
||||
delta.BytesIn += rawDeltaInt
|
||||
} else {
|
||||
if delta.BytesOut > math.MaxInt64-rawDeltaInt {
|
||||
continue
|
||||
}
|
||||
delta.BytesOut += rawDeltaInt
|
||||
}
|
||||
stateInputs = append(stateInputs, stateInput)
|
||||
deltaByForward[sample.ForwardID] = delta
|
||||
}
|
||||
|
||||
forwardIDs := make([]int64, 0, len(deltaByForward))
|
||||
for forwardID := range deltaByForward {
|
||||
forwardIDs = append(forwardIDs, forwardID)
|
||||
}
|
||||
sort.Slice(forwardIDs, func(i, j int) bool { return forwardIDs[i] < forwardIDs[j] })
|
||||
|
||||
deltas := make([]nftTrafficDelta, 0, len(forwardIDs))
|
||||
for _, forwardID := range forwardIDs {
|
||||
delta := deltaByForward[forwardID]
|
||||
if delta.BytesIn == 0 && delta.BytesOut == 0 {
|
||||
continue
|
||||
}
|
||||
deltas = append(deltas, delta)
|
||||
}
|
||||
return deltas, stateInputs
|
||||
}
|
||||
|
||||
func buildNftFlowUploadBatch(deltas []nftTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
batch := flowUploadBatch{
|
||||
quotaUsage: make(map[int64]int64),
|
||||
forwardTraffic: make(map[int64]tunnelTrafficDelta),
|
||||
orphanServices: make(map[string]struct{}),
|
||||
peerShareForwardItems: make(map[string]flowItem),
|
||||
peerShareRuntimeItems: make(map[int64]flowItem),
|
||||
}
|
||||
policySeen := map[flowPolicyTarget]struct{}{}
|
||||
flowSeen := map[int64]int{}
|
||||
|
||||
for _, delta := range deltas {
|
||||
meta, exists := metas[delta.ForwardID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
raw := batch.forwardTraffic[delta.ForwardID]
|
||||
if delta.BytesIn < 0 || delta.BytesOut < 0 || raw.bytesIn > math.MaxInt64-delta.BytesIn || raw.bytesOut > math.MaxInt64-delta.BytesOut {
|
||||
continue
|
||||
}
|
||||
|
||||
scaledIn, ok := scaleNftTrafficBytes(delta.BytesIn, meta.TrafficRatio, meta.TunnelFlow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
scaledOut, ok := scaleNftTrafficBytes(delta.BytesOut, meta.TrafficRatio, meta.TunnelFlow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if scaledIn > math.MaxInt64-scaledOut {
|
||||
continue
|
||||
}
|
||||
quotaDelta := scaledIn + scaledOut
|
||||
if batch.quotaUsage[meta.UserID] > math.MaxInt64-quotaDelta {
|
||||
continue
|
||||
}
|
||||
|
||||
flowIdx, flowExists := flowSeen[delta.ForwardID]
|
||||
if flowExists && (batch.flowDeltas[flowIdx].InFlow > math.MaxInt64-scaledIn || batch.flowDeltas[flowIdx].OutFlow > math.MaxInt64-scaledOut) {
|
||||
continue
|
||||
}
|
||||
|
||||
raw.bytesIn += delta.BytesIn
|
||||
raw.bytesOut += delta.BytesOut
|
||||
batch.forwardTraffic[delta.ForwardID] = raw
|
||||
|
||||
if flowExists {
|
||||
batch.flowDeltas[flowIdx].InFlow += scaledIn
|
||||
batch.flowDeltas[flowIdx].OutFlow += scaledOut
|
||||
} else {
|
||||
flowSeen[delta.ForwardID] = len(batch.flowDeltas)
|
||||
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{
|
||||
ForwardID: delta.ForwardID,
|
||||
UserID: meta.UserID,
|
||||
UserTunnelID: meta.UserTunnelID,
|
||||
InFlow: scaledIn,
|
||||
OutFlow: scaledOut,
|
||||
})
|
||||
}
|
||||
batch.quotaUsage[meta.UserID] += quotaDelta
|
||||
|
||||
target := flowPolicyTarget{UserID: meta.UserID, UserTunnelID: meta.UserTunnelID}
|
||||
if _, seen := policySeen[target]; !seen {
|
||||
policySeen[target] = struct{}{}
|
||||
batch.policyTargets = append(batch.policyTargets, target)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(batch.policyTargets, func(i, j int) bool {
|
||||
if batch.policyTargets[i].UserID == batch.policyTargets[j].UserID {
|
||||
return batch.policyTargets[i].UserTunnelID < batch.policyTargets[j].UserTunnelID
|
||||
}
|
||||
return batch.policyTargets[i].UserID < batch.policyTargets[j].UserID
|
||||
})
|
||||
|
||||
return batch
|
||||
}
|
||||
|
||||
func scaleNftTrafficBytes(bytes int64, ratio float64, tunnelFlow int64) (int64, bool) {
|
||||
if bytes < 0 || ratio < 0 || tunnelFlow < 0 {
|
||||
return 0, false
|
||||
}
|
||||
var scaled int64
|
||||
if ratio == 1 {
|
||||
scaled = bytes
|
||||
} else {
|
||||
scaledFloat := float64(bytes) * ratio
|
||||
if math.IsNaN(scaledFloat) || math.IsInf(scaledFloat, 0) || scaledFloat < 0 || scaledFloat >= math.Pow(2, 63) {
|
||||
return 0, false
|
||||
}
|
||||
scaled = int64(scaledFloat)
|
||||
}
|
||||
if tunnelFlow != 0 && scaled > math.MaxInt64/tunnelFlow {
|
||||
return 0, false
|
||||
}
|
||||
return scaled * tunnelFlow, true
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
runtimenft "go-backend/internal/runtime/nftables"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildNftCounterDeltasSavesFirstBaselineWithoutDelta(t *testing.T) {
|
||||
nowMs := int64(1700000000123)
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
}, nil, map[int64]string{42: "hash-a"}, nowMs)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected no deltas for first baseline, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected one state input, got %d", len(states))
|
||||
}
|
||||
state := states[0]
|
||||
if state.NodeID != 11 || state.ForwardID != 42 || state.Protocol != "tcp" || state.Direction != runtimenft.CounterDirectionToTarget {
|
||||
t.Fatalf("unexpected state identity: %#v", state)
|
||||
}
|
||||
if state.RuleHash != "hash-a" || state.Bytes != 1000 || state.Packets != 10 || state.CollectedTime != nowMs {
|
||||
t.Fatalf("unexpected state values: %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasNormalGrowthProducesDirectionalBytes(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2600, Packets: 26},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 2000, Packets: 20},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 1 {
|
||||
t.Fatalf("expected one aggregated delta, got %#v", deltas)
|
||||
}
|
||||
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != 500 || deltas[0].BytesOut != 600 {
|
||||
t.Fatalf("unexpected delta: %#v", deltas[0])
|
||||
}
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("expected two state inputs, got %d", len(states))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasResetRefreshesBaselineWithoutDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 25, Packets: 2},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected reset to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 1 || states[0].Bytes != 25 || states[0].RuleHash != "hash-a" {
|
||||
t.Fatalf("expected refreshed baseline state, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasRuleHashChangeRefreshesBaselineWithoutDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-b"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected rule hash change to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 1 || states[0].Bytes != 1500 || states[0].RuleHash != "hash-b" {
|
||||
t.Fatalf("expected refreshed hash baseline state, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasEqualBytesRefreshesBaselineWithoutDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 11},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected equal bytes to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 1 || states[0].Bytes != 1000 || states[0].Packets != 11 || states[0].RuleHash != "hash-a" {
|
||||
t.Fatalf("expected refreshed baseline state, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasAggregatesProtocolsAndDirections(t *testing.T) {
|
||||
deltas, _ := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1100, Packets: 11},
|
||||
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 2200, Packets: 22},
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 3300, Packets: 33},
|
||||
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 4400, Packets: 44},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000},
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 2000},
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 3000},
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 4000},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 1 {
|
||||
t.Fatalf("expected one aggregated delta, got %#v", deltas)
|
||||
}
|
||||
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != 300 || deltas[0].BytesOut != 700 {
|
||||
t.Fatalf("unexpected aggregated delta: %#v", deltas[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasSkipsInvalidProtocolBeforeStateAndDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "icmp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "icmp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected invalid protocol to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected invalid protocol to produce no state inputs, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasSkipsOversizedPacketsBeforeStateAndDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: uint64(math.MaxInt64) + 1},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected oversized packets to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected oversized packets to produce no state inputs, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasSkipsOversizedBytesBeforeStateAndDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64) + 1, Packets: 10},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected oversized bytes to produce no deltas, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected oversized bytes to produce no state inputs, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasSkipsOverflowingAggregateSampleWithoutState(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64), Packets: 10},
|
||||
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 10, Packets: 1},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1, Packets: 1},
|
||||
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1, Packets: 1},
|
||||
}, map[int64]string{42: "hash-a"}, 2000)
|
||||
|
||||
if len(deltas) != 1 {
|
||||
t.Fatalf("expected only non-overflowing aggregate delta, got %#v", deltas)
|
||||
}
|
||||
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != math.MaxInt64-1 || deltas[0].BytesOut != 0 {
|
||||
t.Fatalf("unexpected aggregate delta: %#v", deltas[0])
|
||||
}
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected only the accounted safe sample to advance baseline, got %#v", states)
|
||||
}
|
||||
if states[0].ForwardID != 42 || states[0].Protocol != "tcp" || states[0].Bytes != uint64(math.MaxInt64) {
|
||||
t.Fatalf("expected safe sample state input to be preserved, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftCounterDeltasSkipsUnknownDirectionAndOversizedDelta(t *testing.T) {
|
||||
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
|
||||
{ForwardID: 42, Protocol: "tcp", Direction: "sideways", Bytes: 1500, Packets: 15},
|
||||
{ForwardID: 43, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64) + 1, Packets: 1},
|
||||
}, []model.NftCounterState{
|
||||
{NodeID: 11, ForwardID: 43, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-b", Bytes: 100},
|
||||
}, map[int64]string{42: "hash-a", 43: "hash-b"}, 2000)
|
||||
|
||||
if len(deltas) != 0 {
|
||||
t.Fatalf("expected no delta for skipped/oversized samples, got %#v", deltas)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected no state inputs for skipped/oversized samples, got %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftFlowUploadBatchScalesFlowAndPreservesRawTunnelTraffic(t *testing.T) {
|
||||
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: 80, BytesOut: 110},
|
||||
{ForwardID: 21, BytesIn: 7, BytesOut: 11},
|
||||
{ForwardID: 20, BytesIn: 20, BytesOut: 10},
|
||||
}, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TunnelID: 1, TrafficRatio: 2, TunnelFlow: 3},
|
||||
21: {ForwardID: 21, UserID: 2, UserTunnelID: 10, TunnelID: 1, TrafficRatio: 1.5, TunnelFlow: 2},
|
||||
})
|
||||
|
||||
if len(batch.flowDeltas) != 2 {
|
||||
t.Fatalf("expected two flow deltas, got %#v", batch.flowDeltas)
|
||||
}
|
||||
if batch.flowDeltas[0].ForwardID != 20 || batch.flowDeltas[0].InFlow != 600 || batch.flowDeltas[0].OutFlow != 720 {
|
||||
t.Fatalf("unexpected first flow delta: %#v", batch.flowDeltas[0])
|
||||
}
|
||||
if batch.flowDeltas[1].ForwardID != 21 || batch.flowDeltas[1].InFlow != 20 || batch.flowDeltas[1].OutFlow != 32 {
|
||||
t.Fatalf("unexpected second flow delta: %#v", batch.flowDeltas[1])
|
||||
}
|
||||
if batch.quotaUsage[2] != 1372 {
|
||||
t.Fatalf("expected quota usage 1372, got %d", batch.quotaUsage[2])
|
||||
}
|
||||
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("expected deduped policy target, got %#v", batch.policyTargets)
|
||||
}
|
||||
if traffic := batch.forwardTraffic[20]; traffic.bytesIn != 100 || traffic.bytesOut != 120 {
|
||||
t.Fatalf("expected raw traffic for forward 20, got %#v", traffic)
|
||||
}
|
||||
if traffic := batch.forwardTraffic[21]; traffic.bytesIn != 7 || traffic.bytesOut != 11 {
|
||||
t.Fatalf("expected raw traffic for forward 21, got %#v", traffic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftFlowUploadBatchSkipsOverflowingScaledFlow(t *testing.T) {
|
||||
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
|
||||
}, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 2, TunnelFlow: 2},
|
||||
})
|
||||
|
||||
if len(batch.flowDeltas) != 0 {
|
||||
t.Fatalf("expected overflowing scaled flow to be skipped, got %#v", batch.flowDeltas)
|
||||
}
|
||||
if len(batch.quotaUsage) != 0 {
|
||||
t.Fatalf("expected no quota usage for overflowing scaled flow, got %#v", batch.quotaUsage)
|
||||
}
|
||||
if len(batch.policyTargets) != 0 {
|
||||
t.Fatalf("expected no policy targets for overflowing scaled flow, got %#v", batch.policyTargets)
|
||||
}
|
||||
if len(batch.forwardTraffic) != 0 {
|
||||
t.Fatalf("expected no raw traffic for overflowing scaled flow, got %#v", batch.forwardTraffic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftFlowUploadBatchSkipsRawForwardTrafficOverflow(t *testing.T) {
|
||||
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
|
||||
{ForwardID: 20, BytesIn: 1, BytesOut: 0},
|
||||
}, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 0.5, TunnelFlow: 1},
|
||||
})
|
||||
|
||||
traffic := batch.forwardTraffic[20]
|
||||
if traffic.bytesIn != math.MaxInt64 || traffic.bytesOut != 0 {
|
||||
t.Fatalf("expected overflowing raw delta to be skipped without negative traffic, got %#v", traffic)
|
||||
}
|
||||
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 {
|
||||
t.Fatalf("expected only the safe flow delta, got %#v", batch.flowDeltas)
|
||||
}
|
||||
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("expected policy target only from safe delta, got %#v", batch.policyTargets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftFlowUploadBatchSkipsQuotaOverflow(t *testing.T) {
|
||||
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
|
||||
{ForwardID: 21, BytesIn: 1, BytesOut: 0},
|
||||
}, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
|
||||
21: {ForwardID: 21, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
|
||||
})
|
||||
|
||||
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 || batch.flowDeltas[0].InFlow != math.MaxInt64 {
|
||||
t.Fatalf("expected only non-overflowing quota delta, got %#v", batch.flowDeltas)
|
||||
}
|
||||
if batch.quotaUsage[2] != math.MaxInt64 {
|
||||
t.Fatalf("expected quota usage to remain at max int64, got %#v", batch.quotaUsage)
|
||||
}
|
||||
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("expected one policy target from non-overflowing delta, got %#v", batch.policyTargets)
|
||||
}
|
||||
if _, ok := batch.forwardTraffic[21]; ok {
|
||||
t.Fatalf("expected quota-overflowing delta to be skipped from raw traffic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNftFlowUploadBatchSkipsMissingMeta(t *testing.T) {
|
||||
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: 80, BytesOut: 110},
|
||||
{ForwardID: 99, BytesIn: 1, BytesOut: 2},
|
||||
}, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
|
||||
})
|
||||
|
||||
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 {
|
||||
t.Fatalf("expected only forward 20 delta, got %#v", batch.flowDeltas)
|
||||
}
|
||||
if _, ok := batch.forwardTraffic[99]; ok {
|
||||
t.Fatalf("expected missing meta forward to be skipped from raw traffic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNftBatchCoversDeltasRequiresRawAndFlowEntries(t *testing.T) {
|
||||
deltas := []nftTrafficDelta{{ForwardID: 20, BytesIn: 1, BytesOut: 0}}
|
||||
batch := flowUploadBatch{
|
||||
forwardTraffic: map[int64]tunnelTrafficDelta{20: {bytesIn: 1}},
|
||||
flowDeltas: []repo.FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 1}},
|
||||
}
|
||||
if missing, ok := firstNftBatchMissingDelta(deltas, batch); ok || missing != 0 {
|
||||
t.Fatalf("expected batch to cover delta, missing=%d ok=%v", missing, ok)
|
||||
}
|
||||
|
||||
delete(batch.forwardTraffic, 20)
|
||||
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
|
||||
t.Fatalf("expected missing raw traffic for forward 20, got missing=%d ok=%v", missing, ok)
|
||||
}
|
||||
|
||||
batch.forwardTraffic[20] = tunnelTrafficDelta{bytesIn: 1}
|
||||
batch.flowDeltas = nil
|
||||
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
|
||||
t.Fatalf("expected missing flow delta for forward 20, got missing=%d ok=%v", missing, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNftBatchCoversDeltasRequiresAggregateRawTotals(t *testing.T) {
|
||||
deltas := []nftTrafficDelta{
|
||||
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
|
||||
{ForwardID: 20, BytesIn: 1, BytesOut: 0},
|
||||
}
|
||||
batch := buildNftFlowUploadBatch(deltas, map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 0.5, TunnelFlow: 1},
|
||||
})
|
||||
|
||||
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
|
||||
t.Fatalf("expected aggregate raw overflow/mismatch for forward 20, got missing=%d ok=%v", missing, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficFirstBaselineSavesStateWithoutFlow(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2000, Packets: 20},
|
||||
}}
|
||||
h.nftablesManager = manager
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
if manager.collectHit != 1 {
|
||||
t.Fatalf("expected one collection, got %d", manager.collectHit)
|
||||
}
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("expected two baseline states, got %+v", states)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no forward flow on baseline, got %d", got)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT out_flow FROM user WHERE id = 1`); got != 0 {
|
||||
t.Fatalf("expected no user flow on baseline, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficGrowthAppliesFlowAndUpdatesState(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2000, Packets: 20},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2600, Packets: 26},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
|
||||
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 400 {
|
||||
t.Fatalf("expected forward in_flow=400, got %d", got)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT out_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 600 {
|
||||
t.Fatalf("expected forward out_flow=600, got %d", got)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM user WHERE id = 1`); got != 400 {
|
||||
t.Fatalf("expected user in_flow=400, got %d", got)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT out_flow FROM user_tunnel WHERE id = ?`, fixture.userTunnelID); got != 600 {
|
||||
t.Fatalf("expected user_tunnel out_flow=600, got %d", got)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT COALESCE((SELECT daily_used_bytes FROM user_quota WHERE user_id = 1), 0)`); got != 1000 {
|
||||
t.Fatalf("expected daily quota usage=1000, got %d", got)
|
||||
}
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("expected two states after growth, got %+v", states)
|
||||
}
|
||||
for _, state := range states {
|
||||
if state.Direction == runtimenft.CounterDirectionToTarget && state.Bytes != 1400 {
|
||||
t.Fatalf("expected to-target state bytes 1400, got %+v", state)
|
||||
}
|
||||
if state.Direction == runtimenft.CounterDirectionFromTarget && state.Bytes != 2600 {
|
||||
t.Fatalf("expected from-target state bytes 2600, got %+v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficSkippedBatchDeltaDoesNotAdvanceState(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
if err := h.repo.DB().Exec(`UPDATE tunnel SET traffic_ratio = 2 WHERE id = (SELECT tunnel_id FROM forward WHERE id = ?)`, fixture.forwardID).Error; err != nil {
|
||||
t.Fatalf("update tunnel ratio: %v", err)
|
||||
}
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 0, Packets: 0},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64), Packets: 1},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected one state, got %+v", states)
|
||||
}
|
||||
if states[0].Bytes != 0 || states[0].Packets != 0 {
|
||||
t.Fatalf("expected state to remain at old baseline after skipped batch delta, got %+v", states[0])
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no forward flow for skipped batch delta, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficMetadataErrorDoesNotAdvanceState(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
manager := &fakeNftablesManager{}
|
||||
h.nftablesManager = manager
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
if err := h.repo.DB().Exec(`DROP TABLE tunnel`).Error; err != nil {
|
||||
t.Fatalf("drop tunnel table: %v", err)
|
||||
}
|
||||
manager.counterSamples = []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
|
||||
}
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected one baseline state, got %+v", states)
|
||||
}
|
||||
if states[0].Bytes != 1000 || states[0].Packets != 10 {
|
||||
t.Fatalf("expected state to remain at first baseline after metadata failure, got %+v", states[0])
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no flow after metadata failure, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficMissingMetaDoesNotAdvanceState(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
forwardID := int64(4242)
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
|
||||
ForwardID: forwardID,
|
||||
NodeID: fixture.nodeID,
|
||||
InPort: 20000,
|
||||
Protocols: "tcp",
|
||||
TargetAddr: "203.0.113.9:8080",
|
||||
RuleHash: "hash-a",
|
||||
Status: runtimenft.StatusApplied,
|
||||
}, nowMs); err != nil {
|
||||
t.Fatalf("seed stale applied binding: %v", err)
|
||||
}
|
||||
if err := h.repo.UpsertNftCounterStates([]repo.NftCounterStateInput{{
|
||||
NodeID: fixture.nodeID,
|
||||
ForwardID: forwardID,
|
||||
Protocol: "tcp",
|
||||
Direction: runtimenft.CounterDirectionToTarget,
|
||||
RuleHash: "hash-a",
|
||||
Bytes: 1000,
|
||||
Packets: 10,
|
||||
CollectedTime: nowMs,
|
||||
}}, nowMs); err != nil {
|
||||
t.Fatalf("seed counter state: %v", err)
|
||||
}
|
||||
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
|
||||
{ForwardID: forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
|
||||
}}
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected one state, got %+v", states)
|
||||
}
|
||||
if states[0].Bytes != 1000 || states[0].Packets != 10 {
|
||||
t.Fatalf("expected state to remain at old baseline when meta is missing, got %+v", states[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficSkipsSamplesWithoutBinding(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
if err := h.repo.DeleteNftRuleBindingsByForward(fixture.forwardID); err != nil {
|
||||
t.Fatalf("delete nft binding: %v", err)
|
||||
}
|
||||
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
}}
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected no state for unbound sample, got %+v", states)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no flow for unbound sample, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficSkipsNonAppliedBinding(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
if err := h.repo.MarkNftRuleBindingError(fixture.forwardID, fixture.nodeID, "apply failed", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("mark binding error: %v", err)
|
||||
}
|
||||
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
|
||||
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
|
||||
}}
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected no state for non-applied binding, got %+v", states)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no flow for non-applied binding, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectNftablesNodeTrafficCollectionErrorDoesNotWriteState(t *testing.T) {
|
||||
fixture := setupNftablesCollectionFixture(t)
|
||||
h := fixture.handler
|
||||
h.nftablesManager = &fakeNftablesManager{collectErr: errors.New("ssh failed")}
|
||||
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
|
||||
|
||||
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
|
||||
|
||||
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load states: %v", err)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("expected no state on collection error, got %+v", states)
|
||||
}
|
||||
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
|
||||
t.Fatalf("expected no flow on collection error, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type nftablesCollectionFixture struct {
|
||||
handler *Handler
|
||||
nodeID int64
|
||||
forwardID int64
|
||||
userTunnelID int64
|
||||
}
|
||||
|
||||
func setupNftablesCollectionFixture(t *testing.T) nftablesCollectionFixture {
|
||||
t.Helper()
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "nft-traffic-tunnel", fixture.nodeID)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(1, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("seed user_tunnel: %v", err)
|
||||
}
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
|
||||
ForwardID: forward.ID,
|
||||
NodeID: fixture.nodeID,
|
||||
InPort: 20000,
|
||||
Protocols: "tcp,udp",
|
||||
TargetAddr: "203.0.113.9:8080",
|
||||
RuleHash: "hash-a",
|
||||
Status: runtimenft.StatusApplied,
|
||||
}, now); err != nil {
|
||||
t.Fatalf("seed nft binding: %v", err)
|
||||
}
|
||||
userTunnelID := mustHandlerCount(t, h, `SELECT id FROM user_tunnel WHERE user_id = 1 AND tunnel_id = ?`, tunnelID)
|
||||
return nftablesCollectionFixture{
|
||||
handler: h,
|
||||
nodeID: fixture.nodeID,
|
||||
forwardID: forward.ID,
|
||||
userTunnelID: userTunnelID,
|
||||
}
|
||||
}
|
||||
|
||||
func mustCollectionSSHConfig(t *testing.T, h *Handler, nodeID int64) *model.NodeSSHConfig {
|
||||
t.Helper()
|
||||
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("load ssh config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func mustHandlerCount(t *testing.T, h *Handler, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := h.repo.DB().Raw(query, args...).Row().Scan(&value); err != nil {
|
||||
t.Fatalf("query %q failed: %v", query, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
const passkeyTTL = 2 * time.Minute
|
||||
|
||||
type passkeyCeremony struct {
|
||||
userID int64
|
||||
kind string
|
||||
origin string
|
||||
session webauthn.SessionData
|
||||
}
|
||||
|
||||
type passkeyUser struct {
|
||||
id int64
|
||||
name string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u passkeyUser) WebAuthnID() []byte {
|
||||
id := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(id, uint64(u.id))
|
||||
return id
|
||||
}
|
||||
func (u passkeyUser) WebAuthnName() string { return u.name }
|
||||
func (u passkeyUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
|
||||
func passkeyConfig() (*webauthn.WebAuthn, string) {
|
||||
raw := strings.TrimSpace(os.Getenv("FLVX_WEBAUTHN_ORIGIN"))
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" || strings.ContainsAny(host, " /\\") || (u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1"))) {
|
||||
return nil, ""
|
||||
}
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
wa, err := webauthn.New(&webauthn.Config{
|
||||
RPID: host,
|
||||
RPDisplayName: "FLVX",
|
||||
RPOrigins: []string{origin},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
||||
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
return wa, origin
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
wa, _ := passkeyConfig()
|
||||
response.WriteJSON(w, response.OK(map[string]bool{"enabled": wa != nil}))
|
||||
}
|
||||
|
||||
func (h *Handler) putPasskeyCeremony(c passkeyCeremony) (string, bool) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", false
|
||||
}
|
||||
id := base64.RawURLEncoding.EncodeToString(buf)
|
||||
h.passkeyMu.Lock()
|
||||
defer h.passkeyMu.Unlock()
|
||||
for k, v := range h.passkeyPending {
|
||||
if time.Now().After(v.session.Expires) {
|
||||
delete(h.passkeyPending, k)
|
||||
}
|
||||
}
|
||||
if len(h.passkeyPending) >= 1000 {
|
||||
return "", false
|
||||
}
|
||||
h.passkeyPending[id] = c
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (webauthn.SessionData, bool) {
|
||||
h.passkeyMu.Lock()
|
||||
c, ok := h.passkeyPending[id]
|
||||
delete(h.passkeyPending, id)
|
||||
h.passkeyMu.Unlock()
|
||||
if !ok || c.kind != kind || c.origin != origin || c.userID != userID || time.Now().After(c.session.Expires) {
|
||||
return webauthn.SessionData{}, false
|
||||
}
|
||||
return c.session, true
|
||||
}
|
||||
|
||||
func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) {
|
||||
h.passkeyMu.Lock()
|
||||
c, ok := h.passkeyPending[id]
|
||||
delete(h.passkeyPending, id)
|
||||
h.passkeyMu.Unlock()
|
||||
if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) {
|
||||
return passkeyCeremony{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Status != 1 {
|
||||
return passkeyUser{}, errInvalidPasskey
|
||||
}
|
||||
rows, err := h.repo.ListPasskeys(userID)
|
||||
if err != nil {
|
||||
return passkeyUser{}, err
|
||||
}
|
||||
u := passkeyUser{id: user.ID, name: user.User}
|
||||
for _, row := range rows {
|
||||
var credential webauthn.Credential
|
||||
if err := json.Unmarshal([]byte(row.CredentialJSON), &credential); err != nil {
|
||||
return passkeyUser{}, err
|
||||
}
|
||||
u.credentials = append(u.credentials, credential)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
var errInvalidPasskey = &passkeyError{}
|
||||
|
||||
type passkeyError struct{}
|
||||
|
||||
func (*passkeyError) Error() string { return "invalid passkey user" }
|
||||
|
||||
func passkeyBody(r *http.Request, out interface{}) bool {
|
||||
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(out) == nil
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
wa, origin := passkeyConfig()
|
||||
if wa == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
|
||||
return
|
||||
}
|
||||
userID, err := userIDFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !passkeyBody(r, &req) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Status != 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号不可用"))
|
||||
return
|
||||
}
|
||||
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||
return
|
||||
}
|
||||
u, err := h.loadPasskeyUser(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
|
||||
return
|
||||
}
|
||||
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||
return
|
||||
}
|
||||
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: userID, kind: "register", origin: origin, session: *session})
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
|
||||
}
|
||||
|
||||
type passkeyFinishRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
wa, origin := passkeyConfig()
|
||||
if wa == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
|
||||
return
|
||||
}
|
||||
userID, err := userIDFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
var req passkeyFinishRequest
|
||||
if !passkeyBody(r, &req) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
session, ok := h.takePasskeyCeremony(req.SessionID, "register", origin, userID)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
|
||||
return
|
||||
}
|
||||
u, err := h.loadPasskeyUser(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("账号不可用"))
|
||||
return
|
||||
}
|
||||
credential, err := wa.FinishRegistration(u, session, credentialRequest(r, req.Credential))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||
return
|
||||
}
|
||||
credentialJSON, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if len([]rune(name)) > 100 {
|
||||
name = string([]rune(name)[:100])
|
||||
}
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
err = h.repo.CreatePasskey(&model.Passkey{ID: base64.RawURLEncoding.EncodeToString(credential.ID), UserID: userID, Name: name, CredentialJSON: string(credentialJSON), CreatedAt: time.Now().UnixMilli()})
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(nil))
|
||||
}
|
||||
|
||||
func credentialRequest(original *http.Request, body []byte) *http.Request {
|
||||
r := original.Clone(original.Context())
|
||||
r.Body = http.NoBody
|
||||
if len(body) > 0 {
|
||||
r.Body = ioNopCloser{bytes.NewReader(body)}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type ioNopCloser struct{ *bytes.Reader }
|
||||
|
||||
func (ioNopCloser) Close() error { return nil }
|
||||
|
||||
func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
wa, origin := passkeyConfig()
|
||||
if wa == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||
return
|
||||
}
|
||||
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
wa, origin := passkeyConfig()
|
||||
if wa == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
|
||||
return
|
||||
}
|
||||
var req passkeyFinishRequest
|
||||
if !passkeyBody(r, &req) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
|
||||
return
|
||||
}
|
||||
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
// The credential ID determines ownership. A supplied userHandle cannot select another account.
|
||||
if len(rawID) == 0 || len(userHandle) != 8 {
|
||||
return nil, errInvalidPasskey
|
||||
}
|
||||
row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID))
|
||||
if lookupErr != nil || row == nil {
|
||||
return nil, errInvalidPasskey
|
||||
}
|
||||
owner, loadErr := h.loadPasskeyUser(row.UserID)
|
||||
if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) {
|
||||
return nil, errInvalidPasskey
|
||||
}
|
||||
return owner, nil
|
||||
}, pending.session, credentialRequest(r, req.Credential))
|
||||
var u passkeyUser
|
||||
if err == nil {
|
||||
var valid bool
|
||||
u, valid = resolved.(passkeyUser)
|
||||
if !valid {
|
||||
err = errInvalidPasskey
|
||||
}
|
||||
}
|
||||
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||
return
|
||||
}
|
||||
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
stored, err := h.repo.GetPasskey(u.id, credentialID)
|
||||
if err != nil || stored == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||
return
|
||||
}
|
||||
updated, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||
return
|
||||
}
|
||||
ok, err = h.repo.UpdatePasskeyCredential(u.id, credentialID, stored.CredentialJSON, string(updated), time.Now().UnixMilli())
|
||||
if err != nil || !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||
return
|
||||
}
|
||||
user, err := h.repo.GetUserByID(u.id)
|
||||
if err != nil || user == nil || user.Status != 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号不可用"))
|
||||
return
|
||||
}
|
||||
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, time.Now())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("登录失败"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"token": token, "name": user.User, "role_id": user.RoleID, "requirePasswordChange": user.User == "admin_user"}))
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
userID, err := userIDFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
rows, err := h.repo.ListPasskeys(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("获取通行证密钥失败"))
|
||||
return
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, map[string]interface{}{"id": row.ID, "name": row.Name, "createdAt": row.CreatedAt, "lastUsedAt": row.LastUsedAt})
|
||||
}
|
||||
response.WriteJSON(w, response.OK(out))
|
||||
}
|
||||
|
||||
func (h *Handler) passkeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
userID, err := userIDFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !passkeyBody(r, &req) || req.ID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Status != 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号不可用"))
|
||||
return
|
||||
}
|
||||
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||
return
|
||||
}
|
||||
deleted, err := h.repo.DeletePasskey(userID, req.ID)
|
||||
if err != nil || !deleted {
|
||||
response.WriteJSON(w, response.ErrDefault("通行证密钥不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(nil))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
|
||||
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
|
||||
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
|
||||
if !ok {
|
||||
t.Fatal("could not create challenge")
|
||||
}
|
||||
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
|
||||
t.Fatal("valid challenge was rejected")
|
||||
}
|
||||
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
|
||||
t.Fatal("challenge was reusable")
|
||||
}
|
||||
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
|
||||
if !ok {
|
||||
t.Fatal("could not create expired challenge")
|
||||
}
|
||||
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
|
||||
t.Fatal("expired challenge was accepted")
|
||||
}
|
||||
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
|
||||
if !ok {
|
||||
t.Fatal("could not create discoverable challenge")
|
||||
}
|
||||
if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok {
|
||||
t.Fatal("wrong origin was accepted")
|
||||
}
|
||||
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
|
||||
t.Fatal("failed discoverable challenge was reusable")
|
||||
}
|
||||
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
|
||||
if !ok {
|
||||
t.Fatal("could not create discoverable challenge")
|
||||
}
|
||||
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok {
|
||||
t.Fatal("valid discoverable challenge was rejected")
|
||||
}
|
||||
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
|
||||
t.Fatal("discoverable challenge was reusable")
|
||||
}
|
||||
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
|
||||
if !ok {
|
||||
t.Fatal("could not create expired discoverable challenge")
|
||||
}
|
||||
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
|
||||
t.Fatal("expired discoverable challenge was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPeerShareRestrictedShareAllowsOnlyAuthenticatedCleanup(t *testing.T) {
|
||||
for _, state := range []string{"disabled", "expired", "over-quota"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
changes := map[string]interface{}{"allowed_ips": "203.0.113.10", "allowed_domains": "owner.example"}
|
||||
switch state {
|
||||
case "disabled":
|
||||
changes["is_active"] = 0
|
||||
case "expired":
|
||||
changes["expiry_time"] = time.Now().Add(-time.Hour).UnixMilli()
|
||||
case "over-quota":
|
||||
changes["max_bandwidth"] = 1
|
||||
changes["current_flow"] = 2
|
||||
}
|
||||
if err := agent.h.repo.DB().Model(&repo.PeerShare{}).Where("id = ?", runtime.ShareID).Updates(changes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
command string
|
||||
allowed bool
|
||||
}{
|
||||
{"release-role", true}, {"DeleteService", true}, {"DeleteChains", true}, {"DeleteLimiters", true}, {"DeleteCLimiters", true},
|
||||
{"AddService", false}, {"UpdateService", false}, {"ResumeService", false}, {"PauseService", false}, {"DeleteEverything", false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
path := "/api/v1/federation/runtime/command"
|
||||
body := fmt.Sprintf(`{"commandType":%q,"data":{"services":["70_1_0_tcp"]}}`, test.command)
|
||||
if test.command == "release-role" {
|
||||
path = "/api/v1/federation/runtime/release-role"
|
||||
body = `{"reservationId":"role-reservation"}`
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer role-recovery-token")
|
||||
req.Header.Set("X-Panel-Domain", "owner.example")
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
reached := false
|
||||
next := func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
received, err := io.ReadAll(r.Body)
|
||||
if err != nil || string(received) != body {
|
||||
t.Errorf("auth consumed request body: %s %v", received, err)
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
agent.h.authPeer(next)(httptest.NewRecorder(), req)
|
||||
if reached != test.allowed {
|
||||
t.Fatalf("restricted %s %s allowed=%t want=%t", state, test.command, reached, test.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, invalid := range []string{"token", "domain", "ip"} {
|
||||
t.Run("reject-"+invalid, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
|
||||
req.Header.Set("Authorization", "Bearer role-recovery-token")
|
||||
req.Header.Set("X-Panel-Domain", "owner.example")
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
switch invalid {
|
||||
case "token":
|
||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||
case "domain":
|
||||
req.Header.Set("X-Panel-Domain", "other.example")
|
||||
case "ip":
|
||||
req.RemoteAddr = "198.51.100.1:12345"
|
||||
}
|
||||
reached := false
|
||||
agent.h.authPeer(func(http.ResponseWriter, *http.Request) { reached = true })(httptest.NewRecorder(), req)
|
||||
if reached {
|
||||
t.Fatalf("cleanup bypassed %s authentication", invalid)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Exercise the actual release handler through auth, not only the gate.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
|
||||
req.Header.Set("Authorization", "Bearer role-recovery-token")
|
||||
req.Header.Set("X-Panel-Domain", "owner.example")
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
res := httptest.NewRecorder()
|
||||
agent.h.authPeer(agent.h.federationRuntimeReleaseRole)(res, req)
|
||||
var result response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
|
||||
t.Fatalf("authenticated cleanup did not complete: %s %v", res.Body.String(), err)
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Status != 0 {
|
||||
t.Fatalf("cleanup did not release runtime: %+v %v", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareOldReleaseIdentityCannotDeleteReusedReservation(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
old := roleRuntimeFixture(t, agent, "exit")
|
||||
if err := agent.h.releasePeerShareRuntime(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", strings.NewReader(`{"resourceKey":"role-resource","requestedPort":31000,"protocol":"tls"}`))
|
||||
req.Header.Set("Authorization", "Bearer role-recovery-token")
|
||||
res := httptest.NewRecorder()
|
||||
agent.h.federationRuntimeReservePort(res, req)
|
||||
var result response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
|
||||
t.Fatalf("new generation reserve failed: %s %v", res.Body.String(), err)
|
||||
}
|
||||
fresh, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh.ReservationID == old.ReservationID {
|
||||
t.Fatal("reservation identity was reused")
|
||||
}
|
||||
if code := roleRuntimeRequest(t, agent.h, fmt.Sprintf(`{"reservationId":%q,"role":"exit"}`, fresh.ReservationID), false); code != 0 {
|
||||
t.Fatal("new generation apply failed")
|
||||
}
|
||||
fresh, err = agent.h.repo.GetPeerShareRuntimeByID(old.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh.BindingID == old.BindingID || fresh.BindingID == "" {
|
||||
t.Fatal("binding identity was reused")
|
||||
}
|
||||
deletesBefore := len(agent.commandsOfType("DeleteService"))
|
||||
for _, body := range []string{
|
||||
fmt.Sprintf(`{"bindingId":%q,"reservationId":%q,"resourceKey":"role-resource"}`, old.BindingID, old.ReservationID),
|
||||
fmt.Sprintf(`{"reservationId":%q,"resourceKey":"role-resource"}`, old.ReservationID),
|
||||
} {
|
||||
if code := roleRuntimeRequest(t, agent.h, body, true); code != 0 {
|
||||
t.Fatal("obsolete release should acknowledge completion")
|
||||
}
|
||||
}
|
||||
// Also cover an old lookup snapshot waiting behind reservation renewal.
|
||||
if err := agent.h.releasePeerShareRuntime(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
|
||||
if err != nil || after.Status != 1 || after.BindingID != fresh.BindingID {
|
||||
t.Fatalf("old cleanup released new generation: %+v %v", after, err)
|
||||
}
|
||||
if len(agent.commandsOfType("DeleteService")) != deletesBefore {
|
||||
t.Fatal("old cleanup sent deletion for new generation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
// Serialize desired-state changes with reconnect reconciliation. In particular,
|
||||
// a stale reconnect snapshot must never revive an acknowledged release.
|
||||
var peerRoleRuntimeMu sync.Mutex
|
||||
|
||||
// applyPeerShareRoleRuntime requires peerRoleRuntimeMu. Persist the validated
|
||||
// desired configuration before creating resources, so failed or interrupted
|
||||
// commands remain recoverable rather than producing untracked listeners.
|
||||
func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) error {
|
||||
if runtime.ReleasePending != 0 || runtime.Status != 1 {
|
||||
return errors.New("runtime is being released")
|
||||
}
|
||||
if runtime.Role != "middle" && runtime.Role != "exit" {
|
||||
return errors.New("invalid runtime role")
|
||||
}
|
||||
node, err := h.getNodeRecord(runtime.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var targets []federationRuntimeTarget
|
||||
if strings.TrimSpace(runtime.Target) != "" {
|
||||
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var chain map[string]interface{}
|
||||
if runtime.Role == "middle" {
|
||||
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName)
|
||||
runtime.Applied = 0
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.wsServer == nil {
|
||||
return errors.New("node command transport unavailable")
|
||||
}
|
||||
if chain != nil {
|
||||
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateChains", updateChainPayload(runtime.ChainName, chain), false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// UpdateService and UpdateChains are upserts, including on an empty agent.
|
||||
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateService", []map[string]interface{}{service}, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Applied = 1
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
return h.repo.UpdatePeerShareRuntime(runtime)
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareRuntime(runtime *repo.PeerShareRuntime) error {
|
||||
peerRoleRuntimeMu.Lock()
|
||||
defer peerRoleRuntimeMu.Unlock()
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
current, err := h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != nil && (current.ReservationID != runtime.ReservationID || current.BindingID != runtime.BindingID) {
|
||||
// A completed reservation may have been reused while this release
|
||||
// waited for the mutation lock. Never release the new generation.
|
||||
return nil
|
||||
}
|
||||
return h.releasePeerShareRuntimeLocked(current)
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareRuntimeLocked(runtime *repo.PeerShareRuntime) error {
|
||||
if runtime == nil || runtime.Status == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := h.repo.SetPeerShareRuntimeReleasePending(runtime.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.ReleasePending = 1
|
||||
if runtime.Role == "forward" {
|
||||
if _, _, scoped := parsePeerShareServiceName(runtime.ServiceName); scoped {
|
||||
if err := h.releasePeerShareForwardRuntimeResources(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
|
||||
}
|
||||
// Older agents used unscoped names. Do not delete a name shared by
|
||||
// another reservation or by a local forward during migration.
|
||||
owners, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(runtime.NodeID, runtime.ServiceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, owner := range owners {
|
||||
if owner.ShareID != runtime.ShareID {
|
||||
return fmt.Errorf("legacy runtime %q has ambiguous ownership", runtime.ServiceName)
|
||||
}
|
||||
}
|
||||
if forwardID, _, _, ok := parseFlowServiceIDs(normalizeForwardRuntimeServiceName(runtime.ServiceName)); ok {
|
||||
local, err := h.repo.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if local != nil {
|
||||
return fmt.Errorf("legacy runtime %q collides with a local forward", runtime.ServiceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ServiceName is saved before apply; Applied=0 can therefore mean an
|
||||
// unacknowledged command, and must not bypass deletion.
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" || strings.TrimSpace(runtime.ChainName) != "" {
|
||||
if h.wsServer == nil {
|
||||
return errors.New("node command transport unavailable")
|
||||
}
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
names := []string{runtime.ServiceName}
|
||||
if runtime.Role == "forward" {
|
||||
names = append(names, runtime.ServiceName+"_tcp", runtime.ServiceName+"_udp")
|
||||
}
|
||||
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.ChainName) != "" {
|
||||
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) reconcilePeerShareRoleRuntimesOnNode(nodeID int64) error {
|
||||
return h.reconcilePeerShareRoleRuntimes(nodeID, false)
|
||||
}
|
||||
|
||||
// Maintenance retries only unfinished desired state. Replaying acknowledged
|
||||
// services while an unrelated operation is failing can interrupt live traffic.
|
||||
func (h *Handler) retryPendingPeerShareRoleRuntimesOnNode(nodeID int64) error {
|
||||
return h.reconcilePeerShareRoleRuntimes(nodeID, true)
|
||||
}
|
||||
|
||||
func (h *Handler) reconcilePeerShareRoleRuntimes(nodeID int64, pendingOnly bool) error {
|
||||
peerRoleRuntimeMu.Lock()
|
||||
defer peerRoleRuntimeMu.Unlock()
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reconcileErr error
|
||||
for i := range runtimes {
|
||||
runtime := &runtimes[i]
|
||||
if pendingOnly && runtime.Applied == 1 && runtime.ReleasePending == 0 {
|
||||
continue
|
||||
}
|
||||
if runtime.ReleasePending != 0 {
|
||||
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
|
||||
continue
|
||||
}
|
||||
if runtime.Role != "middle" && runtime.Role != "exit" {
|
||||
continue
|
||||
}
|
||||
share, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil {
|
||||
reconcileErr = errors.Join(reconcileErr, err)
|
||||
continue
|
||||
}
|
||||
if share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
|
||||
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
|
||||
continue
|
||||
}
|
||||
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
|
||||
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("shared runtime %d: %w", runtime.ID, err))
|
||||
}
|
||||
}
|
||||
return reconcileErr
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func roleRuntimeFixture(t *testing.T, agent *cleanupAgent, role string) *repo.PeerShareRuntime {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
share := &repo.PeerShare{Name: "role-recovery", NodeID: 1, Token: "role-recovery-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
|
||||
if err := agent.h.repo.CreatePeerShare(share); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &repo.PeerShareRuntime{ShareID: share.ID, NodeID: 1, ReservationID: "role-reservation", ResourceKey: "role-resource", BindingID: "333", Role: role, ServiceName: "fed_svc_333", Protocol: "tls", Strategy: "round", Port: 31000, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}
|
||||
if role == "middle" {
|
||||
runtime.ChainName = "fed_chain_333"
|
||||
runtime.Target = `[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
|
||||
}
|
||||
if err := agent.h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Runtime-generated names use the provider's globally unique runtime ID.
|
||||
runtime.BindingID = fmt.Sprint(runtime.ID)
|
||||
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
if role == "middle" {
|
||||
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
|
||||
}
|
||||
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
func roleRuntimeRequest(t *testing.T, h *Handler, body string, release bool) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/runtime", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer role-recovery-token")
|
||||
res := httptest.NewRecorder()
|
||||
if release {
|
||||
h.federationRuntimeReleaseRole(res, req)
|
||||
} else {
|
||||
h.federationRuntimeApplyRole(res, req)
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("runtime response: %s", res.Body.String())
|
||||
return result.Code
|
||||
}
|
||||
|
||||
func TestPeerShareAppliedRuntimeRepairsEmptyAgent(t *testing.T) {
|
||||
for _, role := range []string{"exit", "middle"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
roleRuntimeFixture(t, agent, role)
|
||||
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
|
||||
t.Fatal("reconnect reconciliation failed")
|
||||
}
|
||||
if got := len(agent.commandsOfType("UpdateService")); got != 1 {
|
||||
t.Fatalf("reconnect did not restore service: %d", got)
|
||||
}
|
||||
targets := ""
|
||||
if role == "middle" {
|
||||
targets = `,"targets":[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
|
||||
}
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"`+role+`","protocol":"tls"`+targets+`}`, false); code != 0 {
|
||||
t.Fatalf("apply failed: %d", code)
|
||||
}
|
||||
if got := len(agent.commandsOfType("UpdateService")); got != 2 {
|
||||
t.Fatalf("Applied=1 skipped service repair: %d", got)
|
||||
}
|
||||
if role == "middle" {
|
||||
if got := len(agent.commandsOfType("UpdateChains")); got != 2 {
|
||||
t.Fatalf("missing middle chains: %d", got)
|
||||
}
|
||||
agent.mu.Lock()
|
||||
defer agent.mu.Unlock()
|
||||
chainSeen := false
|
||||
for _, cmd := range agent.commands {
|
||||
if cmd.Type == "UpdateChains" {
|
||||
chainSeen = true
|
||||
}
|
||||
if cmd.Type == "UpdateService" && !chainSeen {
|
||||
t.Fatal("service started before chain")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "middle")
|
||||
liveServer := agent.h.wsServer
|
||||
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
|
||||
t.Fatal("offline release reported success")
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Status != 1 || stored.ReleasePending != 1 {
|
||||
t.Fatalf("pending release lost: %+v err=%v", stored, err)
|
||||
}
|
||||
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
|
||||
if err != nil || !occupied {
|
||||
t.Fatalf("pending release freed occupied port: %t %v", occupied, err)
|
||||
}
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32000}]}`, false); code == 0 {
|
||||
t.Fatal("pending release was revived by apply")
|
||||
}
|
||||
agent.h.wsServer = liveServer
|
||||
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
|
||||
t.Fatal("reconnect cleanup failed")
|
||||
}
|
||||
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Status != 0 || stored.Applied != 0 || stored.ReleasePending != 0 {
|
||||
t.Fatalf("release not completed: %+v %v", stored, err)
|
||||
}
|
||||
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("DeleteChains")) != 1 {
|
||||
t.Fatal("reconnect did not delete both service and chain")
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 0 || len(agent.commandsOfType("UpdateChains")) != 0 {
|
||||
t.Fatal("reconnect revived pending release")
|
||||
}
|
||||
occupied, err = agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
|
||||
if err != nil || occupied {
|
||||
t.Fatalf("acknowledged release still occupies port: %t %v", occupied, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
callback := "test:fail-role-desired-write"
|
||||
if err := agent.h.repo.DB().Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "peer_share_runtime" {
|
||||
tx.AddError(errors.New("desired-state write failed"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This database is test-local; keep the callback registered through the
|
||||
// websocket teardown so callback mutation cannot race node status writes.
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
|
||||
t.Fatal("failed ownership persistence reported success")
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("service started before ownership persisted")
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Applied != 1 {
|
||||
t.Fatalf("old runtime changed on failed persistence: %+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareApplyInvalidMiddleKeepsDesiredConfig(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "middle")
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[]}`, false); code == 0 {
|
||||
t.Fatal("invalid middle targets accepted")
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Target != runtime.Target || stored.Applied != 1 {
|
||||
t.Fatalf("invalid update changed desired runtime: %+v %v", stored, err)
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService"))+len(agent.commandsOfType("UpdateChains")) != 0 {
|
||||
t.Fatal("invalid config sent to agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareUnacknowledgedApplyRecoversOrReleases(t *testing.T) {
|
||||
for _, release := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("release=%t", release), func(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "middle")
|
||||
liveServer := agent.h.wsServer
|
||||
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32001,"protocol":"tls"}]}`, false); code == 0 {
|
||||
t.Fatal("offline apply reported success")
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Applied != 0 || !strings.Contains(stored.Target, "32001") || stored.ServiceName == "" {
|
||||
t.Fatalf("unacknowledged desired state lost: %+v %v", stored, err)
|
||||
}
|
||||
if release {
|
||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
|
||||
t.Fatal("unacknowledged listener was freed offline")
|
||||
}
|
||||
}
|
||||
agent.h.wsServer = liveServer
|
||||
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
|
||||
t.Fatal("reconnect failed")
|
||||
}
|
||||
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release {
|
||||
if stored.Status != 0 || len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatalf("unacknowledged apply revived after release: %+v", stored)
|
||||
}
|
||||
} else {
|
||||
if stored.Status != 1 || stored.Applied != 1 || len(agent.commandsOfType("UpdateService")) != 1 {
|
||||
t.Fatalf("unacknowledged desired state not recovered: %+v", stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareDeleteOfflineRetainsDisabledShare(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
liveServer := agent.h.wsServer
|
||||
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
|
||||
req := httptest.NewRequest(http.MethodPost, "/share/delete", strings.NewReader(fmt.Sprintf(`{"id":%d}`, runtime.ShareID)))
|
||||
res := httptest.NewRecorder()
|
||||
agent.h.federationShareDelete(res, req)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code == 0 {
|
||||
t.Fatal("offline share delete reported success")
|
||||
}
|
||||
share, err := agent.h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || share == nil || share.IsActive != 0 {
|
||||
t.Fatalf("cleanup retry record lost or still accepts allocations: %+v %v", share, err)
|
||||
}
|
||||
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
|
||||
if err != nil || !occupied {
|
||||
t.Fatal("offline share deletion released its port")
|
||||
}
|
||||
agent.h.wsServer = liveServer
|
||||
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
|
||||
t.Fatal("disabled share cleanup did not retry")
|
||||
}
|
||||
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("disabled share was recreated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareLegacyReleaseWithoutLocalCollisionDeletesFamily(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "forward")
|
||||
runtime.ServiceName = "70_1_0"
|
||||
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.h.releasePeerShareRuntime(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands := agent.commandsOfType("DeleteService")
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("expected one family cleanup, got %d", len(commands))
|
||||
}
|
||||
var payload struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(commands[0].Data, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(payload.Services, ",")
|
||||
if got != "70_1_0,70_1_0_tcp,70_1_0_udp" {
|
||||
t.Fatalf("incomplete legacy cleanup: %s", got)
|
||||
}
|
||||
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
|
||||
if err != nil || stored.Status != 0 || stored.ReleasePending != 0 {
|
||||
t.Fatalf("legacy release incomplete: %+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerSharePendingRoleRetryDoesNotReplaySuccessfulServices(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 0 {
|
||||
t.Fatal("maintenance replayed an acknowledged service")
|
||||
}
|
||||
runtime.Applied = 0
|
||||
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 1 {
|
||||
t.Fatal("maintenance did not retry unfinished service")
|
||||
}
|
||||
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 1 {
|
||||
t.Fatal("maintenance repeated a successful retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerShareResourceFailureDoesNotBlockRoleOrLocalRecovery(t *testing.T) {
|
||||
agent := newCleanupAgent(t)
|
||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||
if err := agent.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: runtime.ShareID, NodeID: 1, Kind: "chain", OriginalName: "broken", RuntimeName: "broken", Config: "invalid-json", DesiredState: "active", Applied: 0, UpdatedTime: time.Now().UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var localQueries atomic.Int32
|
||||
if err := agent.h.repo.DB().Callback().Query().Before("gorm:query").Register("test:observe-local-recovery", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "chain_tunnel" || tx.Statement.Table == "forward_port" {
|
||||
localQueries.Add(1)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if agent.h.redeployNodeRuntimeAfterUpgrade(1) {
|
||||
t.Fatal("invalid shared resource was reported recovered")
|
||||
}
|
||||
if len(agent.commandsOfType("UpdateService")) != 1 {
|
||||
t.Fatal("resource failure blocked independent role service recovery")
|
||||
}
|
||||
if localQueries.Load() < 2 {
|
||||
t.Fatal("shared failure blocked independent local recovery")
|
||||
}
|
||||
// A shared failure is owned by pending maintenance, without full-redeploy
|
||||
// timers that would periodically restart healthy listeners.
|
||||
agent.h.onNodeOnline(1)
|
||||
agent.h.upgradeMu.Lock()
|
||||
_, fullQueued := agent.h.nodeOnlineRedeployQueued[1]
|
||||
_, localQueued := agent.h.nodeLocalRuntimeRetryQueued[1]
|
||||
agent.h.upgradeMu.Unlock()
|
||||
if fullQueued || localQueued {
|
||||
t.Fatal("shared-only failure queued a full or local redeploy")
|
||||
}
|
||||
before := len(agent.commandsOfType("UpdateService"))
|
||||
agent.h.retryNodeLocalRuntime(1)
|
||||
if len(agent.commandsOfType("UpdateService")) != before {
|
||||
t.Fatal("local retry replayed shared listeners")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -75,6 +76,12 @@ func IsValidNodeAddress(addr string) error {
|
||||
if strings.ContainsAny(addr, "/?") {
|
||||
return fmt.Errorf("address must not contain path or query parameters")
|
||||
}
|
||||
// A bare IPv6 literal contains multiple colons, so net.SplitHostPort treats
|
||||
// it as a malformed host:port pair. Accept IP literals before attempting
|
||||
// host:port parsing; netip also handles scoped IPv6 addresses.
|
||||
if _, err := netip.ParseAddr(addr); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIssue515IsValidNodeAddressAcceptsBareIPv6(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"2001:db8::1",
|
||||
"::1",
|
||||
"fe80::1%eth0",
|
||||
} {
|
||||
t.Run(addr, func(t *testing.T) {
|
||||
if err := IsValidNodeAddress(addr); err != nil {
|
||||
t.Fatalf("expected bare IPv6 address %q to be accepted: %v", addr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidNodeAddressKeepsExistingAddressForms(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"203.0.113.10",
|
||||
"node.example.com",
|
||||
"node.example.com:6365",
|
||||
"[2001:db8::1]:6365",
|
||||
} {
|
||||
t.Run(addr, func(t *testing.T) {
|
||||
if err := IsValidNodeAddress(addr); err != nil {
|
||||
t.Fatalf("expected node address %q to be accepted: %v", addr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidNodeAddressRejectsURLComponents(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://node.example.com",
|
||||
"node.example.com/path",
|
||||
"node.example.com?transport=tcp",
|
||||
} {
|
||||
t.Run(addr, func(t *testing.T) {
|
||||
if err := IsValidNodeAddress(addr); err == nil {
|
||||
t.Fatalf("expected node address %q to be rejected", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// flowMiB is optional so older clients can keep sending the GB-based flow field.
|
||||
// A positive value takes precedence and preserves sub-GB limits exactly.
|
||||
func parseTrafficLimit(req map[string]interface{}, defaultGB int64) (flowGB, flowMiB int64, err error) {
|
||||
flowGB = asInt64(req["flow"], defaultGB)
|
||||
if flowGB < 0 {
|
||||
return 0, 0, fmt.Errorf("流量限制不能小于0")
|
||||
}
|
||||
raw, present := req["flowMiB"]
|
||||
if !present {
|
||||
return flowGB, 0, nil
|
||||
}
|
||||
flowMiB, err = strconv.ParseInt(asString(raw), 10, 64)
|
||||
if err != nil || flowMiB < 0 || flowMiB > math.MaxInt64/bytesPerMiB {
|
||||
return 0, 0, fmt.Errorf("流量限制超出范围")
|
||||
}
|
||||
if flowMiB > 0 {
|
||||
flowGB = (flowMiB-1)/1024 + 1
|
||||
}
|
||||
return flowGB, flowMiB, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficLimitMiBOverridesLegacyGB(t *testing.T) {
|
||||
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{
|
||||
"flow": float64(1), "flowMiB": float64(500),
|
||||
}, 100)
|
||||
if err != nil || flowGB != 1 || flowMiB != 500 {
|
||||
t.Fatalf("parseTrafficLimit = (%d, %d, %v), want (1, 500, nil)", flowGB, flowMiB, err)
|
||||
}
|
||||
limit := flowLimitBytes(flowGB, flowMiB)
|
||||
if limit != 500*bytesPerMiB {
|
||||
t.Fatalf("limit = %d, want %d", limit, 500*bytesPerMiB)
|
||||
}
|
||||
policy := &userTunnelPolicy{Flow: flowGB, FlowMiB: flowMiB, InFlow: limit - 1, Status: 1}
|
||||
if shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
|
||||
t.Fatal("policy paused before reaching 500 MiB")
|
||||
}
|
||||
policy.InFlow = limit
|
||||
if !shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
|
||||
t.Fatal("policy did not pause at 500 MiB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficLimitLegacyAndInvalidValues(t *testing.T) {
|
||||
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{"flow": float64(2)}, 100)
|
||||
if err != nil || flowGB != 2 || flowMiB != 0 || flowLimitBytes(flowGB, flowMiB) != 2*bytesPerGB {
|
||||
t.Fatalf("legacy GB limit changed: (%d, %d, %v)", flowGB, flowMiB, err)
|
||||
}
|
||||
for _, value := range []interface{}{"1.5", -1, "999999999999999999999"} {
|
||||
if _, _, err := parseTrafficLimit(map[string]interface{}{"flowMiB": value}, 100); err == nil {
|
||||
t.Fatalf("accepted invalid flowMiB %v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,11 @@ func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes
|
||||
ownerNode := nodes[owner.NodeID]
|
||||
for _, exit := range exits {
|
||||
exitNode := nodes[exit.NodeID]
|
||||
if exitNode == nil {
|
||||
if !isTunnelProbeNodeOnline(ownerNode) {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "owner node offline"))
|
||||
continue
|
||||
}
|
||||
if !isTunnelProbeNodeOnline(exitNode) {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable"))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -358,9 +358,9 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
|
||||
{NodeID: 31, NodeName: "exit-b", Port: 30031},
|
||||
}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
10: {ID: 10, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Status: 1, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, Status: 1, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
switch {
|
||||
@@ -387,12 +387,30 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerSkipsOfflineCandidate(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
|
||||
30: {ID: 30, Status: 0, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
|
||||
}
|
||||
ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
t.Fatalf("offline best-exit candidate should not be probed: node=%d target=%s:%d", nodeID, ip, port)
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), ping)
|
||||
if len(scores) != 1 || scores[0].Success {
|
||||
t.Fatalf("expected one failed offline candidate, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
|
||||
30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
|
||||
10: {ID: 10, Name: "entry-a", Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
|
||||
30: {ID: 30, Name: "exit-a", Status: 1, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
|
||||
}
|
||||
target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
|
||||
var calls []string
|
||||
@@ -419,8 +437,8 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testin
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
10: {ID: 10, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Status: 1, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
return 0, 100, errBestExitProbeForTest
|
||||
@@ -436,8 +454,8 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *t
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-v6", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Name: "entry", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Name: "exit-v6", ServerIP: "2001:db8::30", ServerIPv6: "2001:db8::30", TCPListenAddr: "[::]"},
|
||||
10: {ID: 10, Name: "entry", Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Name: "exit-v6", Status: 1, ServerIP: "2001:db8::30", ServerIPv6: "2001:db8::30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
t.Fatalf("ping should not be called when target resolution fails: node=%d ip=%s port=%d", nodeID, ip, port)
|
||||
|
||||
@@ -3,6 +3,8 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -13,7 +15,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
@@ -31,6 +32,26 @@ type TunnelQualityHop struct {
|
||||
TargetPort int `json:"targetPort,omitempty"`
|
||||
}
|
||||
|
||||
type TunnelQualityCandidateHop struct {
|
||||
TunnelQualityHop
|
||||
FromRole string `json:"fromRole"`
|
||||
ToRole string `json:"toRole"`
|
||||
HopIndex int `json:"hopIndex"`
|
||||
Selected bool `json:"selected"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelQualityChainDetails struct {
|
||||
PrimaryPath []TunnelQualityHop `json:"primaryPath,omitempty"`
|
||||
CandidateHops []TunnelQualityCandidateHop `json:"candidateHops,omitempty"`
|
||||
}
|
||||
|
||||
type tunnelQualityCandidateGroup struct {
|
||||
role string
|
||||
roleIndex int
|
||||
nodes []chainNodeRecord
|
||||
}
|
||||
|
||||
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
|
||||
type tunnelQualitySnapshot struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
@@ -56,7 +77,7 @@ type tunnelQualityProber struct {
|
||||
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
interval time.Duration
|
||||
wake chan struct{}
|
||||
lastPrune int64
|
||||
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
|
||||
probeNode bestExitProbeFunc
|
||||
@@ -65,8 +86,8 @@ type tunnelQualityProber struct {
|
||||
// newTunnelQualityProber creates a new prober (not yet running).
|
||||
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
interval: tunnelQualityProbeInterval,
|
||||
handler: h,
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +107,16 @@ func (p *tunnelQualityProber) Stop() {
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) NotifyConfigChanged() {
|
||||
if p == nil || p.wake == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case p.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns all cached quality snapshots (latest per tunnel).
|
||||
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
|
||||
var items []tunnelQualitySnapshot
|
||||
@@ -109,20 +140,44 @@ func (p *tunnelQualityProber) loop() {
|
||||
// Run once immediately
|
||||
p.probeAll()
|
||||
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(p.probeInterval())
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
stopAndDrainTunnelQualityTimer(timer)
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-p.wake:
|
||||
stopAndDrainTunnelQualityTimer(timer)
|
||||
continue
|
||||
case <-timer.C:
|
||||
p.probeAll()
|
||||
p.maybePrune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAndDrainTunnelQualityTimer(timer *time.Timer) {
|
||||
if timer == nil || timer.Stop() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeInterval() time.Duration {
|
||||
if p == nil || p.handler == nil || p.handler.repo == nil {
|
||||
return time.Duration(monitoring.DefaultTunnelQualityProbeIntervalSec) * time.Second
|
||||
}
|
||||
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigTunnelQualityProbeIntervalSec})
|
||||
if err != nil {
|
||||
return time.Duration(monitoring.DefaultTunnelQualityProbeIntervalSec) * time.Second
|
||||
}
|
||||
seconds := monitoring.TunnelQualityProbeIntervalSecondsFromConfigMap(cfg)
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) isEnabled() bool {
|
||||
if p == nil || p.handler == nil {
|
||||
return true
|
||||
@@ -246,15 +301,28 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
options := diagnosisExecOptions{
|
||||
commandTimeout: tunnelQualityProbeTimeout,
|
||||
pingTimeoutMS: tunnelQualityPingTimeoutMs,
|
||||
pingCount: 1,
|
||||
timeoutMessage: "探测超时",
|
||||
}
|
||||
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
|
||||
roundPinger := newBestExitRoundPinger(p.pingNode)
|
||||
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget, roundPinger)
|
||||
|
||||
entry, _, entryOnline := p.firstOnlineChainNode(inNodes)
|
||||
exit, _, exitOnline := p.firstOnlineChainNode(outNodes)
|
||||
selectedNodeIDs := make(map[string]int64, 2+len(midNodesGrouped))
|
||||
if entryOnline {
|
||||
selectedNodeIDs[tunnelQualityGroupKey("entry", 0)] = entry.NodeID
|
||||
}
|
||||
if exitOnline {
|
||||
selectedNodeIDs[tunnelQualityGroupKey("exit", 0)] = exit.NodeID
|
||||
}
|
||||
var primaryHops []TunnelQualityHop
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
// Port forwarding: entry → public probe target only.
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if entryOnline {
|
||||
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
@@ -262,24 +330,42 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
} else {
|
||||
snap.ErrorMessage = "入口节点均不在线"
|
||||
}
|
||||
case 2:
|
||||
// Tunnel forwarding: entry → exit + exit → Bing
|
||||
probeOK := true
|
||||
|
||||
if len(inNodes) > 0 && len(outNodes) > 0 {
|
||||
var hops []TunnelQualityHop
|
||||
if !entryOnline {
|
||||
probeOK = false
|
||||
snap.ErrorMessage = "入口节点均不在线"
|
||||
snap.EntryToExitLatency = -1
|
||||
snap.EntryToExitLoss = 100
|
||||
} else if !exitOnline {
|
||||
probeOK = false
|
||||
snap.ErrorMessage = "出口节点均不在线"
|
||||
snap.EntryToExitLatency = -1
|
||||
snap.EntryToExitLoss = 100
|
||||
} else {
|
||||
var totalLat float64
|
||||
remainingSuccessProb := 1.0
|
||||
|
||||
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
|
||||
nodesInPath = append(nodesInPath, inNodes[0])
|
||||
for _, midGroup := range midNodesGrouped {
|
||||
if len(midGroup) > 0 {
|
||||
nodesInPath = append(nodesInPath, midGroup[0])
|
||||
nodesInPath = append(nodesInPath, entry)
|
||||
for midIndex, midGroup := range midNodesGrouped {
|
||||
mid, _, online := p.firstOnlineChainNode(midGroup)
|
||||
if !online {
|
||||
probeOK = false
|
||||
snap.ErrorMessage = "中间节点组均不在线"
|
||||
break
|
||||
}
|
||||
nodesInPath = append(nodesInPath, mid)
|
||||
selectedNodeIDs[tunnelQualityGroupKey("middle", midIndex)] = mid.NodeID
|
||||
}
|
||||
if probeOK {
|
||||
nodesInPath = append(nodesInPath, exit)
|
||||
}
|
||||
nodesInPath = append(nodesInPath, outNodes[0])
|
||||
|
||||
for i := 0; i < len(nodesInPath)-1; i++ {
|
||||
source := nodesInPath[i]
|
||||
@@ -293,12 +379,12 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
}
|
||||
|
||||
targetNode, nodeErr := h.getNodeRecord(target.NodeID)
|
||||
if nodeErr != nil || targetNode == nil {
|
||||
if nodeErr != nil || !isTunnelProbeNodeOnline(targetNode) {
|
||||
snap.ErrorMessage = "节点 " + target.NodeName + " 不可用"
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
primaryHops = append(primaryHops, hop)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -309,25 +395,25 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
primaryHops = append(primaryHops, hop)
|
||||
break
|
||||
}
|
||||
|
||||
hop.TargetIP = targetIP
|
||||
hop.TargetPort = targetPort
|
||||
|
||||
lat, loss, err := p.pingNode(source.NodeID, targetIP, targetPort, options)
|
||||
lat, loss, err := roundPinger(source.NodeID, targetIP, targetPort, options)
|
||||
if err == nil {
|
||||
hop.Latency = lat
|
||||
hop.Loss = loss
|
||||
totalLat += lat
|
||||
remainingSuccessProb *= (1.0 - loss/100.0)
|
||||
hops = append(hops, hop)
|
||||
primaryHops = append(primaryHops, hop)
|
||||
} else {
|
||||
probeOK = false
|
||||
hop.Latency = -1
|
||||
hop.Loss = 100
|
||||
hops = append(hops, hop)
|
||||
primaryHops = append(primaryHops, hop)
|
||||
if snap.ErrorMessage == "" {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
@@ -342,17 +428,11 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
snap.EntryToExitLatency = -1
|
||||
snap.EntryToExitLoss = 100
|
||||
}
|
||||
|
||||
if len(hops) > 0 {
|
||||
if b, err := json.Marshal(hops); err == nil {
|
||||
snap.ChainDetails = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit → Bing
|
||||
if len(outNodes) > 0 {
|
||||
lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if exitOnline {
|
||||
lat, loss, err := roundPinger(exit.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
@@ -367,8 +447,8 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
snap.Success = probeOK
|
||||
default:
|
||||
// Unknown type: entry → public probe target.
|
||||
if len(inNodes) > 0 {
|
||||
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if entryOnline {
|
||||
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if err == nil {
|
||||
snap.ExitToBingLatency = lat
|
||||
snap.ExitToBingLoss = loss
|
||||
@@ -376,13 +456,215 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
} else {
|
||||
snap.ErrorMessage = err.Error()
|
||||
}
|
||||
} else {
|
||||
snap.ErrorMessage = "入口节点均不在线"
|
||||
}
|
||||
}
|
||||
|
||||
candidateHops := p.probeTunnelCandidateHops(
|
||||
tunnel.Type,
|
||||
inNodes,
|
||||
midNodesGrouped,
|
||||
outNodes,
|
||||
selectedNodeIDs,
|
||||
ipPreference,
|
||||
options,
|
||||
probeTarget,
|
||||
roundPinger,
|
||||
)
|
||||
if len(primaryHops) > 0 || len(candidateHops) > 0 {
|
||||
details := tunnelQualityChainDetails{
|
||||
PrimaryPath: primaryHops,
|
||||
CandidateHops: candidateHops,
|
||||
}
|
||||
if b, err := json.Marshal(details); err == nil {
|
||||
snap.ChainDetails = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
|
||||
func tunnelQualityGroupKey(role string, index int) string {
|
||||
return fmt.Sprintf("%s:%d", role, index)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeTunnelCandidateHops(
|
||||
tunnelType int,
|
||||
inNodes []chainNodeRecord,
|
||||
chainHops [][]chainNodeRecord,
|
||||
outNodes []chainNodeRecord,
|
||||
selectedNodeIDs map[string]int64,
|
||||
ipPreference string,
|
||||
options diagnosisExecOptions,
|
||||
probeTarget tunnelProbeTarget,
|
||||
ping bestExitProbeFunc,
|
||||
) []TunnelQualityCandidateHop {
|
||||
if p == nil || p.handler == nil || ping == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tunnelType != 2 {
|
||||
return p.probePublicTargetCandidates("entry", 0, inNodes, selectedNodeIDs, options, probeTarget, ping)
|
||||
}
|
||||
|
||||
groups := make([]tunnelQualityCandidateGroup, 0, 2+len(chainHops))
|
||||
groups = append(groups, tunnelQualityCandidateGroup{role: "entry", roleIndex: 0, nodes: inNodes})
|
||||
for i, hop := range chainHops {
|
||||
groups = append(groups, tunnelQualityCandidateGroup{role: "middle", roleIndex: i, nodes: hop})
|
||||
}
|
||||
groups = append(groups, tunnelQualityCandidateGroup{role: "exit", roleIndex: 0, nodes: outNodes})
|
||||
|
||||
var items []TunnelQualityCandidateHop
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
items = append(items, p.probeCandidateGroupLinks(
|
||||
groups[i],
|
||||
groups[i+1],
|
||||
i,
|
||||
selectedNodeIDs,
|
||||
ipPreference,
|
||||
options,
|
||||
ping,
|
||||
)...)
|
||||
}
|
||||
items = append(items, p.probePublicTargetCandidates(
|
||||
"exit",
|
||||
0,
|
||||
outNodes,
|
||||
selectedNodeIDs,
|
||||
options,
|
||||
probeTarget,
|
||||
ping,
|
||||
)...)
|
||||
return items
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeCandidateGroupLinks(
|
||||
fromGroup tunnelQualityCandidateGroup,
|
||||
toGroup tunnelQualityCandidateGroup,
|
||||
hopIndex int,
|
||||
selectedNodeIDs map[string]int64,
|
||||
ipPreference string,
|
||||
options diagnosisExecOptions,
|
||||
ping bestExitProbeFunc,
|
||||
) []TunnelQualityCandidateHop {
|
||||
items := make([]TunnelQualityCandidateHop, 0, len(fromGroup.nodes)*len(toGroup.nodes))
|
||||
for _, source := range fromGroup.nodes {
|
||||
for _, target := range toGroup.nodes {
|
||||
item := TunnelQualityCandidateHop{
|
||||
TunnelQualityHop: TunnelQualityHop{
|
||||
FromNodeID: source.NodeID,
|
||||
FromNodeName: source.NodeName,
|
||||
ToNodeID: target.NodeID,
|
||||
ToNodeName: target.NodeName,
|
||||
Latency: -1,
|
||||
Loss: 100,
|
||||
},
|
||||
FromRole: fromGroup.role,
|
||||
ToRole: toGroup.role,
|
||||
HopIndex: hopIndex,
|
||||
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromGroup.role, fromGroup.roleIndex)] == source.NodeID &&
|
||||
selectedNodeIDs[tunnelQualityGroupKey(toGroup.role, toGroup.roleIndex)] == target.NodeID,
|
||||
}
|
||||
|
||||
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
|
||||
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
|
||||
item.ErrorMessage = "来源节点不在线"
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
targetNode, targetErr := p.handler.getNodeRecord(target.NodeID)
|
||||
if targetErr != nil || !isTunnelProbeNodeOnline(targetNode) {
|
||||
item.ErrorMessage = "目标节点不在线"
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
targetIP, targetPort, resolveErr := resolveChainProbeTarget(sourceNode, targetNode, target.Port, ipPreference, target.ConnectIP)
|
||||
if resolveErr != nil {
|
||||
item.ErrorMessage = resolveErr.Error()
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
item.TargetIP = targetIP
|
||||
item.TargetPort = targetPort
|
||||
latency, loss, probeErr := ping(source.NodeID, targetIP, targetPort, options)
|
||||
if probeErr != nil {
|
||||
item.ErrorMessage = probeErr.Error()
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
item.Latency = latency
|
||||
item.Loss = loss
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probePublicTargetCandidates(
|
||||
fromRole string,
|
||||
fromIndex int,
|
||||
nodes []chainNodeRecord,
|
||||
selectedNodeIDs map[string]int64,
|
||||
options diagnosisExecOptions,
|
||||
probeTarget tunnelProbeTarget,
|
||||
ping bestExitProbeFunc,
|
||||
) []TunnelQualityCandidateHop {
|
||||
items := make([]TunnelQualityCandidateHop, 0, len(nodes))
|
||||
for _, source := range nodes {
|
||||
item := TunnelQualityCandidateHop{
|
||||
TunnelQualityHop: TunnelQualityHop{
|
||||
FromNodeID: source.NodeID,
|
||||
FromNodeName: source.NodeName,
|
||||
ToNodeName: formatTunnelProbeTarget(probeTarget),
|
||||
Latency: -1,
|
||||
Loss: 100,
|
||||
TargetIP: probeTarget.Host,
|
||||
TargetPort: probeTarget.Port,
|
||||
},
|
||||
FromRole: fromRole,
|
||||
ToRole: "target",
|
||||
HopIndex: fromIndex,
|
||||
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromRole, fromIndex)] == source.NodeID,
|
||||
}
|
||||
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
|
||||
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
|
||||
item.ErrorMessage = "来源节点不在线"
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
latency, loss, probeErr := ping(source.NodeID, probeTarget.Host, probeTarget.Port, options)
|
||||
if probeErr != nil {
|
||||
item.ErrorMessage = probeErr.Error()
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
item.Latency = latency
|
||||
item.Loss = loss
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func isTunnelProbeNodeOnline(node *nodeRecord) bool {
|
||||
return node != nil && (node.IsRemote == 1 || node.Status == 1)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) firstOnlineChainNode(nodes []chainNodeRecord) (chainNodeRecord, *nodeRecord, bool) {
|
||||
if p == nil || p.handler == nil {
|
||||
return chainNodeRecord{}, nil, false
|
||||
}
|
||||
for _, candidate := range nodes {
|
||||
node, err := p.handler.getNodeRecord(candidate.NodeID)
|
||||
if err == nil && isTunnelProbeNodeOnline(node) {
|
||||
return candidate, node, true
|
||||
}
|
||||
}
|
||||
return chainNodeRecord{}, nil, false
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget, roundPinger bestExitProbeFunc) {
|
||||
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
|
||||
return
|
||||
}
|
||||
@@ -404,9 +686,6 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai
|
||||
nodeMap[exit.NodeID] = node
|
||||
}
|
||||
}
|
||||
// This best-exit decision cache is per decision round; the display-oriented
|
||||
// tunnel quality snapshot may still collect its own first-exit public probe.
|
||||
roundPinger := newBestExitRoundPinger(p.pingNode)
|
||||
for _, owner := range owners {
|
||||
if nodeMap[owner.NodeID] == nil {
|
||||
continue
|
||||
@@ -444,6 +723,9 @@ func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, opt
|
||||
if nodeErr != nil {
|
||||
return 0, 100, nodeErr
|
||||
}
|
||||
if !isTunnelProbeNodeOnline(node) {
|
||||
return 0, 100, errors.New("节点不在线")
|
||||
}
|
||||
|
||||
var pingData map[string]interface{}
|
||||
var pingErr error
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -26,6 +27,9 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
|
||||
p := newTunnelQualityProber(h)
|
||||
var calls []string
|
||||
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
if options.pingCount != 1 {
|
||||
t.Fatalf("expected real-time quality probe count 1, got %d", options.pingCount)
|
||||
}
|
||||
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
||||
return 10, 0, nil
|
||||
}
|
||||
@@ -46,6 +50,114 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberSkipsAllOfflineExits(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedQualityForwardTunnel(t, h, 81, []int{0, 0, 0})
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
probeCalls := 0
|
||||
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
probeCalls++
|
||||
return 0, 100, fmt.Errorf("unexpected probe node=%d target=%s:%d", nodeID, ip, port)
|
||||
}
|
||||
p.probeTunnel(81)
|
||||
|
||||
if probeCalls != 0 {
|
||||
t.Fatalf("expected no TCP probes when all exits are offline, got %d", probeCalls)
|
||||
}
|
||||
snaps := p.GetAll()
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
||||
}
|
||||
if snaps[0].Success || snaps[0].ErrorMessage != "出口节点均不在线" {
|
||||
t.Fatalf("expected offline exit snapshot, got %+v", snaps[0])
|
||||
}
|
||||
if snaps[0].EntryToExitLoss != 100 {
|
||||
t.Fatalf("expected 100%% entry-to-exit loss, got %+v", snaps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberUsesOnlineBackupExit(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedQualityForwardTunnel(t, h, 82, []int{0, 1})
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
var calls []string
|
||||
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
if options.pingCount != 1 {
|
||||
t.Fatalf("expected real-time quality probe count 1, got %d", options.pingCount)
|
||||
}
|
||||
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
|
||||
return 10, 0, nil
|
||||
}
|
||||
p.probeTunnel(82)
|
||||
|
||||
if slices.Contains(calls, "10|10.0.0.30|30030") {
|
||||
t.Fatalf("did not expect probe to offline primary exit, calls=%+v", calls)
|
||||
}
|
||||
if !slices.Contains(calls, "10|10.0.0.31|30031") {
|
||||
t.Fatalf("expected entry probe to online backup exit, calls=%+v", calls)
|
||||
}
|
||||
if !slices.Contains(calls, "31|www.bing.com|443") {
|
||||
t.Fatalf("expected public probe from online backup exit, calls=%+v", calls)
|
||||
}
|
||||
snaps := p.GetAll()
|
||||
if len(snaps) != 1 || !snaps[0].Success {
|
||||
t.Fatalf("expected successful backup exit snapshot, got %+v", snaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberReportsAllExitCandidateLatencies(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedQualityForwardTunnel(t, h, 83, []int{1, 1})
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
switch fmt.Sprintf("%d|%s|%d", nodeID, ip, port) {
|
||||
case "10|10.0.0.30|30030":
|
||||
return 20, 0, nil
|
||||
case "10|10.0.0.31|30031":
|
||||
return 35, 0, nil
|
||||
case "30|www.bing.com|443":
|
||||
return 50, 0, nil
|
||||
case "31|www.bing.com|443":
|
||||
return 65, 0, nil
|
||||
default:
|
||||
return 0, 100, fmt.Errorf("unexpected probe node=%d target=%s:%d", nodeID, ip, port)
|
||||
}
|
||||
}
|
||||
p.probeTunnel(83)
|
||||
|
||||
snaps := p.GetAll()
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("expected one quality snapshot, got %+v", snaps)
|
||||
}
|
||||
if snaps[0].EntryToExitLatency != 20 || snaps[0].ExitToBingLatency != 50 {
|
||||
t.Fatalf("expected primary path metrics to remain unchanged, got %+v", snaps[0])
|
||||
}
|
||||
|
||||
var details tunnelQualityChainDetails
|
||||
if err := json.Unmarshal([]byte(snaps[0].ChainDetails), &details); err != nil {
|
||||
t.Fatalf("decode chain details: %v", err)
|
||||
}
|
||||
assertCandidateHop := func(fromID, toID int64, latency float64, selected bool) {
|
||||
t.Helper()
|
||||
for _, hop := range details.CandidateHops {
|
||||
if hop.FromNodeID == fromID && hop.ToNodeID == toID {
|
||||
if hop.Latency != latency || hop.Selected != selected || hop.ErrorMessage != "" {
|
||||
t.Fatalf("unexpected candidate hop: %+v", hop)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("candidate hop %d -> %d not found in %+v", fromID, toID, details.CandidateHops)
|
||||
}
|
||||
assertCandidateHop(10, 30, 20, true)
|
||||
assertCandidateHop(10, 31, 35, false)
|
||||
assertCandidateHop(30, 0, 50, true)
|
||||
assertCandidateHop(31, 0, 65, false)
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
|
||||
@@ -67,3 +179,69 @@ func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
|
||||
t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberUsesConfiguredInterval(t *testing.T) {
|
||||
h := setupProbeTargetTunnelHandler(t)
|
||||
if err := h.repo.UpsertConfig("monitor_tunnel_quality_interval_sec", "15", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("upsert interval config: %v", err)
|
||||
}
|
||||
|
||||
p := newTunnelQualityProber(h)
|
||||
if got := p.probeInterval(); got != 15*time.Second {
|
||||
t.Fatalf("probe interval = %s, want 15s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelQualityProberConfigNotificationIsCoalesced(t *testing.T) {
|
||||
p := newTunnelQualityProber(nil)
|
||||
p.NotifyConfigChanged()
|
||||
p.NotifyConfigChanged()
|
||||
|
||||
if got := len(p.wake); got != 1 {
|
||||
t.Fatalf("wake notifications = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelQualityProbeIntervalConfigValue(t *testing.T) {
|
||||
got, err := normalizeAndValidateConfigValue("monitor_tunnel_quality_interval_sec", " 15 ")
|
||||
if err != nil || got != "15" {
|
||||
t.Fatalf("normalize interval = %q, %v", got, err)
|
||||
}
|
||||
if _, err := normalizeAndValidateConfigValue("monitor_tunnel_quality_interval_sec", "0"); err == nil {
|
||||
t.Fatalf("expected invalid interval to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func seedQualityForwardTunnel(t *testing.T, h *Handler, tunnelID int64, exitStatuses []int) {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port)
|
||||
VALUES(?, ?, 1, 2, 'tls', 1, ?, ?, 1, ?, '', '', 0)
|
||||
`, tunnelID, fmt.Sprintf("quality-forward-%d", tunnelID), now, now, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert forwarding tunnel: %v", err)
|
||||
}
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, '1', 10, 30001, 'fifo', 1, 'tls')
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
for i, status := range exitStatuses {
|
||||
nodeID := int64(30 + i)
|
||||
port := 30030 + i
|
||||
ip := fmt.Sprintf("10.0.0.%d", nodeID)
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, '', '30000-30100', '', 'v1', 1, 1, 1, ?, ?, ?, '[::]', '[::]', 0)
|
||||
`, nodeID, fmt.Sprintf("exit-%d", i+1), fmt.Sprintf("exit-secret-%d", i+1), ip, ip, now, now, status).Error; err != nil {
|
||||
t.Fatalf("insert exit node %d: %v", nodeID, err)
|
||||
}
|
||||
if err := h.repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, '3', ?, ?, 'fifo', ?, 'tls')
|
||||
`, tunnelID, nodeID, port, i+1).Error; err != nil {
|
||||
t.Fatalf("insert exit chain %d: %v", nodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,15 +398,80 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if h == nil || h.repo == nil || h.wsServer == nil {
|
||||
return
|
||||
}
|
||||
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
|
||||
return // The next connection will resume any pending reconciliation.
|
||||
}
|
||||
if !h.startNodeOnlineRedeploy(nodeID, time.Now()) {
|
||||
return
|
||||
}
|
||||
defer h.finishNodeOnlineRedeploy(nodeID)
|
||||
|
||||
// Reconcile node runtime on the first reconnect, but suppress rapid flapping
|
||||
// so websocket churn does not trigger repeated full redeploy storms.
|
||||
if !h.redeployNodeRuntimeAfterUpgrade(nodeID) {
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
// A fresh agent needs a full restore. Shared failures remain persisted and
|
||||
// are retried by maintenance without restarting acknowledged services.
|
||||
h.reconcileSharedNodeRuntime(nodeID)
|
||||
if !h.redeployLocalNodeRuntime(nodeID) {
|
||||
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
||||
} else {
|
||||
h.clearNodeLocalRuntimeRetry(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Local retries are separate from reconnect reconciliation: a failed local
|
||||
// forward must not cause every shared listener to be replayed every 30 seconds.
|
||||
func (h *Handler) scheduleNodeLocalRuntimeRetry(nodeID int64) {
|
||||
h.upgradeMu.Lock()
|
||||
defer h.upgradeMu.Unlock()
|
||||
if h.nodeLocalRuntimeRetryQueued == nil {
|
||||
h.nodeLocalRuntimeRetryQueued = make(map[int64]struct{})
|
||||
}
|
||||
if _, queued := h.nodeLocalRuntimeRetryQueued[nodeID]; queued {
|
||||
return
|
||||
}
|
||||
h.nodeLocalRuntimeRetryQueued[nodeID] = struct{}{}
|
||||
time.AfterFunc(nodeOnlineRedeployCooldown, func() {
|
||||
h.upgradeMu.Lock()
|
||||
_, queued := h.nodeLocalRuntimeRetryQueued[nodeID]
|
||||
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
if !queued {
|
||||
return
|
||||
}
|
||||
h.retryNodeLocalRuntime(nodeID)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) clearNodeLocalRuntimeRetry(nodeID int64) {
|
||||
h.upgradeMu.Lock()
|
||||
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) retryNodeLocalRuntime(nodeID int64) {
|
||||
if h == nil || h.repo == nil || h.wsServer == nil {
|
||||
return
|
||||
}
|
||||
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
if _, inFlight := h.nodeOnlineRedeploying[nodeID]; inFlight {
|
||||
h.upgradeMu.Unlock()
|
||||
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
||||
return
|
||||
}
|
||||
if h.nodeOnlineRedeploying == nil {
|
||||
h.nodeOnlineRedeploying = make(map[int64]struct{})
|
||||
}
|
||||
h.nodeOnlineRedeploying[nodeID] = struct{}{}
|
||||
h.upgradeMu.Unlock()
|
||||
defer h.finishNodeOnlineRedeploy(nodeID)
|
||||
if !h.redeployLocalNodeRuntime(nodeID) {
|
||||
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
||||
} else {
|
||||
h.clearNodeLocalRuntimeRetry(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +568,25 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
||||
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
|
||||
localOK := h.redeployLocalNodeRuntime(nodeID)
|
||||
return sharedOK && localOK
|
||||
}
|
||||
|
||||
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
|
||||
succeeded := true
|
||||
if err := h.reconcilePeerShareResourcesOnNode(nodeID); err != nil {
|
||||
fmt.Printf("reconnect shared resource reconciliation failed on node %d: %v\n", nodeID, err)
|
||||
succeeded = false
|
||||
}
|
||||
if err := h.reconcilePeerShareRoleRuntimesOnNode(nodeID); err != nil {
|
||||
fmt.Printf("reconnect shared role reconciliation failed on node %d: %v\n", nodeID, err)
|
||||
succeeded = false
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
func (h *Handler) redeployLocalNodeRuntime(nodeID int64) bool {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
@@ -567,6 +651,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
|
||||
return true
|
||||
}
|
||||
|
||||
permanentFailure := false
|
||||
const maxRetries = 3
|
||||
baseDelay := time.Second
|
||||
|
||||
@@ -580,7 +665,8 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
|
||||
delete(tunnelFailed, tunnelID)
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d succeeded on node %d (attempt %d)\n", tunnelID, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
delete(tunnelFailed, tunnelID) // Non-retryable, don't retry again
|
||||
permanentFailure = true
|
||||
delete(tunnelFailed, tunnelID) // Preserve failure while avoiding immediate retries.
|
||||
} else {
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d still failing on node %d (attempt %d): %v\n", tunnelID, nodeID, attempt, err)
|
||||
}
|
||||
@@ -596,7 +682,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
|
||||
if err := h.syncForwardServices(ff.forward, "UpdateService", true); err == nil {
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d succeeded on node %d (attempt %d)\n", ff.id, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
// Non-retryable, drop it
|
||||
permanentFailure = true // Keep reconciliation pending for a later reconnect.
|
||||
} else {
|
||||
stillFailed = append(stillFailed, ff)
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d still failing on node %d (attempt %d): %v\n", ff.id, nodeID, attempt, err)
|
||||
@@ -606,7 +692,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
|
||||
|
||||
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
|
||||
fmt.Printf("post-upgrade redeploy retry: all items recovered on node %d\n", nodeID)
|
||||
return true
|
||||
return !permanentFailure
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestStartNodeOnlineRedeploySkipsRecentReconnects(t *testing.T) {
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
now := time.Now()
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
@@ -34,7 +34,7 @@ func TestStartNodeOnlineRedeployAllowsPendingUpgradeDuringCooldown(t *testing.T)
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
now := time.Now()
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
@@ -57,7 +57,7 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
now := time.Now()
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
@@ -67,7 +67,10 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected cooldown reconnect to skip immediate redeploy")
|
||||
}
|
||||
if _, queued := h.nodeOnlineRedeployQueued[54]; !queued {
|
||||
h.upgradeMu.Lock()
|
||||
_, queued := h.nodeOnlineRedeployQueued[54]
|
||||
h.upgradeMu.Unlock()
|
||||
if !queued {
|
||||
t.Fatalf("expected cooldown reconnect to queue a follow-up redeploy")
|
||||
}
|
||||
}
|
||||
@@ -79,7 +82,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
now := time.Now()
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
@@ -96,7 +99,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNextNodeOnlineRedeployFireAtDefersExpiredInFlightReconnect(t *testing.T) {
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
now := time.Now()
|
||||
last := now.Add(-nodeOnlineRedeployCooldown - 5*time.Second)
|
||||
|
||||
fireAt, start := nextNodeOnlineRedeployFireAt(last, now, false, true)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
func TestUserUpdateDisablesPermissionsAndRunningForwards(t *testing.T) {
|
||||
for _, password := range []string{"", "replacement-password"} {
|
||||
t.Run(fmt.Sprintf("password_changed=%t", password != ""), func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
now := time.Now().UnixMilli()
|
||||
for _, userID := range []int64{2, 3} {
|
||||
if err := a.h.repo.DB().Create(&model.User{ID: userID, User: fmt.Sprintf("user%d", userID), Pwd: "pwd", RoleID: 1, Status: 1, ExpTime: now + 86400000, Flow: 100, Num: 10}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 1, Name: "test", Type: 1, Protocol: "tls", Status: 1, Flow: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, permission := range []model.UserTunnel{
|
||||
{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||
{ID: 11, UserID: 2, TunnelID: 2, Status: 0, Flow: 100, Num: 10},
|
||||
{ID: 12, UserID: 3, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||
} {
|
||||
if err := a.h.repo.DB().Create(&permission).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, forward := range []model.Forward{
|
||||
{ID: 20, UserID: 2, UserName: "user2", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
|
||||
{ID: 21, UserID: 2, UserName: "user2", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 0},
|
||||
{ID: 22, UserID: 3, UserName: "user3", Name: "other-user", TunnelID: 1, RemoteAddr: "127.0.0.1:82", Status: 1},
|
||||
} {
|
||||
if err := a.h.repo.DB().Create(&forward).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: 1, Port: 31000 + int(forward.ID)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
update := func(status int) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]interface{}{"id": 2, "user": "user2", "status": status, "pwd": password, "flow": 100, "num": 10, "expTime": now + 86400000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
a.h.userUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(payload)))
|
||||
var result response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
t.Fatalf("update user: %d %s", result.Code, result.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
update(1)
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 1 {
|
||||
t.Errorf("ordinary account update disabled an enabled permission: %d", got)
|
||||
}
|
||||
if len(a.commandsOfType("PauseService")) != 0 {
|
||||
t.Error("ordinary account update paused services")
|
||||
}
|
||||
update(0)
|
||||
for _, check := range []struct {
|
||||
table string
|
||||
id int64
|
||||
want int
|
||||
}{{"user", 2, 0}, {"user_tunnel", 10, 0}, {"user_tunnel", 11, 0}, {"user_tunnel", 12, 1}, {"forward", 20, 0}, {"forward", 21, 0}, {"forward", 22, 1}} {
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM "+check.table+" WHERE id = ?", check.id); got != check.want {
|
||||
t.Errorf("%s %d status = %d, want %d", check.table, check.id, got, check.want)
|
||||
}
|
||||
}
|
||||
commands := a.commandsOfType("PauseService")
|
||||
if len(commands) == 0 {
|
||||
t.Error("running forward never received PauseService")
|
||||
}
|
||||
for _, command := range commands {
|
||||
var data struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(command.Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range data.Services {
|
||||
if !strings.HasPrefix(name, "20_2_") {
|
||||
t.Errorf("paused unrelated service %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
update(0)
|
||||
if len(a.commandsOfType("PauseService")) != len(commands) {
|
||||
t.Error("repeated disable replayed pauses for already paused forwards")
|
||||
}
|
||||
|
||||
// Account edits and re-enabling must not grant disabled permissions or
|
||||
// resume forwards that an administrator previously paused.
|
||||
update(1)
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
|
||||
t.Errorf("account re-enable unexpectedly granted a disabled permission: %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 11"); got != 0 {
|
||||
t.Errorf("re-enabled independently disabled permission: %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
|
||||
t.Errorf("resumed previously paused forward: %d", got)
|
||||
}
|
||||
if len(a.commandsOfType("ResumeService")) != 0 {
|
||||
t.Error("account re-enable unexpectedly resumed services")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDisableMultiEntryNeverRecreatesMissingServices(t *testing.T) {
|
||||
for _, secondNodeState := range []string{"missing", "offline", "timeout"} {
|
||||
t.Run(secondNodeState, func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
var remoteMu sync.Mutex
|
||||
remoteServices := make(map[string]bool)
|
||||
var remoteCommands []string
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var command struct {
|
||||
Type string `json:"commandType"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
remoteMu.Lock()
|
||||
defer remoteMu.Unlock()
|
||||
remoteCommands = append(remoteCommands, command.Type)
|
||||
switch command.Type {
|
||||
case "UpdateService", "AddService":
|
||||
var services []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(command.Data, &services); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for _, service := range services {
|
||||
remoteServices[service.Name] = true
|
||||
}
|
||||
case "PauseService":
|
||||
if secondNodeState != "missing" {
|
||||
message := "节点不在线"
|
||||
if secondNodeState == "timeout" {
|
||||
message = "等待节点响应超时"
|
||||
}
|
||||
response.WriteJSON(w, response.ErrDefault(message))
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(command.Data, &data); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for _, name := range data.Services {
|
||||
if _, exists := remoteServices[name]; !exists {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"success": false, "message": "service not found"}))
|
||||
return
|
||||
}
|
||||
remoteServices[name] = false
|
||||
}
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
|
||||
}))
|
||||
defer remote.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
userID, err := a.h.repo.CreateUser("multi-entry-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []interface{}{
|
||||
&model.Node{ID: 2, Name: "second-entry", Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: "test-token", Valid: true}},
|
||||
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 1, Status: 1, Protocol: "tls", Flow: 1},
|
||||
&model.ChainTunnel{TunnelID: 1, NodeID: 1, ChainType: "1"},
|
||||
&model.ChainTunnel{TunnelID: 1, NodeID: 2, ChainType: "1"},
|
||||
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||
&model.Forward{ID: 20, UserID: userID, UserName: "multi-entry-disable", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
|
||||
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
|
||||
&model.ForwardPort{ForwardID: 20, NodeID: 2, Port: 31020},
|
||||
} {
|
||||
if err := a.h.repo.DB().Create(value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "multi-entry-disable", "status": 0})
|
||||
var out response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStatus := 1
|
||||
if secondNodeState == "missing" {
|
||||
wantStatus = 0
|
||||
if out.Code != 0 {
|
||||
t.Errorf("missing service should count as stopped: %s", out.Msg)
|
||||
}
|
||||
} else if out.Code == 0 {
|
||||
t.Error("unconfirmed second entry pause returned success")
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != wantStatus {
|
||||
t.Errorf("forward status = %d, want %d", got, wantStatus)
|
||||
}
|
||||
a.mu.Lock()
|
||||
commands := append([]cleanupAgentCommand(nil), a.commands...)
|
||||
a.mu.Unlock()
|
||||
firstEntryRunning := true
|
||||
for _, command := range commands {
|
||||
switch command.Type {
|
||||
case "PauseService":
|
||||
firstEntryRunning = false
|
||||
case "UpdateService", "AddService", "ResumeService":
|
||||
firstEntryRunning = true
|
||||
t.Errorf("disable sent %s to already paused entry A", command.Type)
|
||||
}
|
||||
}
|
||||
if firstEntryRunning {
|
||||
t.Error("entry A is running after disable")
|
||||
}
|
||||
remoteMu.Lock()
|
||||
defer remoteMu.Unlock()
|
||||
if len(remoteCommands) == 0 {
|
||||
t.Error("entry B was never checked")
|
||||
}
|
||||
for _, command := range remoteCommands {
|
||||
if command != "PauseService" {
|
||||
t.Errorf("disable sent %s to entry B", command)
|
||||
}
|
||||
}
|
||||
for name, running := range remoteServices {
|
||||
if running {
|
||||
t.Errorf("entry B service %s is still running", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDisableContinuesAcrossNodesAndForwards(t *testing.T) {
|
||||
for _, failure := range []string{"节点不在线", "等待节点响应超时", "pause rejected"} {
|
||||
for _, failedNodeFirst := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/failed_node_first=%t", failure, failedNodeFirst), func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
var mu sync.Mutex
|
||||
recovered := false
|
||||
attempts := make(map[string]int)
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var command struct {
|
||||
Type string `json:"commandType"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if command.Type != "PauseService" {
|
||||
t.Errorf("disable sent %s", command.Type)
|
||||
}
|
||||
mu.Lock()
|
||||
attempts[r.Header.Get("Authorization")]++
|
||||
isRecovered := recovered
|
||||
mu.Unlock()
|
||||
if !isRecovered {
|
||||
response.WriteJSON(w, response.ErrDefault(failure))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
|
||||
}))
|
||||
defer remote.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
userID, err := a.h.repo.CreateUser("continue-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []interface{}{
|
||||
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 2, Status: 1, Flow: 1},
|
||||
&model.Node{ID: 4, Name: "offline-middle", Secret: "test", ServerIP: "127.0.0.1", Status: 0},
|
||||
&model.ChainTunnel{TunnelID: 1, NodeID: 4, ChainType: "2"},
|
||||
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||
&model.Forward{ID: 20, UserID: userID, UserName: "continue-disable", Name: "mixed-nodes", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
|
||||
&model.Forward{ID: 21, UserID: userID, UserName: "continue-disable", Name: "healthy-forward", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 1},
|
||||
} {
|
||||
if err := a.h.repo.DB().Create(value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, nodeID := range []int64{2, 3} {
|
||||
node := model.Node{ID: nodeID, Name: fmt.Sprintf("failed-node-%d", nodeID), Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: fmt.Sprintf("test-node-%d", nodeID), Valid: true}}
|
||||
if err := a.h.repo.DB().Create(&node).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
nodeOrder := []int64{1, 2, 3}
|
||||
if failedNodeFirst {
|
||||
nodeOrder = []int64{2, 1, 3}
|
||||
}
|
||||
for _, nodeID := range nodeOrder {
|
||||
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 20, NodeID: nodeID, Port: 31020}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.ChainTunnel{TunnelID: 1, NodeID: nodeID, ChainType: "1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 21, NodeID: 1, Port: 31021}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := func() response.R {
|
||||
t.Helper()
|
||||
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "continue-disable", "status": 0})
|
||||
var out response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := update()
|
||||
if out.Code == 0 {
|
||||
t.Error("partial node failure reported complete success")
|
||||
}
|
||||
state, ok := out.Data.(map[string]interface{})
|
||||
if !ok || state["userDisabled"] != true {
|
||||
t.Errorf("partial response did not tell the UI the account is disabled: %#v", out.Data)
|
||||
}
|
||||
for _, nodeID := range []int64{2, 3} {
|
||||
if !strings.Contains(out.Msg, fmt.Sprintf("failed-node-%d", nodeID)) || !strings.Contains(out.Msg, fmt.Sprintf("ID %d", nodeID)) {
|
||||
t.Errorf("failure response did not identify node %d: %s", nodeID, out.Msg)
|
||||
}
|
||||
mu.Lock()
|
||||
count := attempts[fmt.Sprintf("Bearer test-node-%d", nodeID)]
|
||||
mu.Unlock()
|
||||
if count == 0 {
|
||||
t.Errorf("failure on another node prevented node %d from being attempted", nodeID)
|
||||
}
|
||||
}
|
||||
paused := make(map[string]bool)
|
||||
for _, command := range a.commandsOfType("PauseService") {
|
||||
var data struct {
|
||||
Services []string `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(command.Data, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range data.Services {
|
||||
paused[strings.Split(name, "_")[0]] = true
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"20", "21"} {
|
||||
if !paused[id] {
|
||||
t.Errorf("healthy node never paused forward %s", id)
|
||||
}
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
|
||||
t.Errorf("unconfirmed forward status = %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
|
||||
t.Errorf("healthy later forward was not paused: %d", got)
|
||||
}
|
||||
mu.Lock()
|
||||
recovered = true
|
||||
mu.Unlock()
|
||||
if retry := update(); retry.Code != 0 {
|
||||
t.Errorf("retry after recovery failed: %s", retry.Msg)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
|
||||
t.Errorf("retry did not confirm all nodes: %d", got)
|
||||
}
|
||||
for _, kind := range []string{"UpdateService", "AddService", "ResumeService"} {
|
||||
if len(a.commandsOfType(kind)) != 0 {
|
||||
t.Errorf("disable sent %s", kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDisableRemovesNftablesRuleOnlyAfterConfirmedApply(t *testing.T) {
|
||||
fixture := setupNftablesHandler(t)
|
||||
h := fixture.handler
|
||||
t.Cleanup(func() { _ = h.repo.Close() })
|
||||
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||
tunnelID := seedTunnelForNftables(t, h, "disable-nft", fixture.nodeID)
|
||||
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
|
||||
now := time.Now().UnixMilli()
|
||||
userID, err := h.repo.CreateUser("disable-nft", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.repo.DB().Model(&model.Forward{}).Where("id = ?", forward.ID).Update("user_id", userID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.repo.DB().Create(&model.UserTunnel{UserID: userID, TunnelID: tunnelID, Status: 1, Flow: 100, Num: 10}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherForwardID := forward.ID + 100
|
||||
for _, value := range []interface{}{
|
||||
&model.Forward{ID: otherForwardID, UserID: 1, UserName: "admin", Name: "other-user", TunnelID: tunnelID, RemoteAddr: "203.0.113.9:8081", Status: 1},
|
||||
&model.ForwardPort{ForwardID: otherForwardID, NodeID: fixture.nodeID, Port: 20001},
|
||||
} {
|
||||
if err := h.repo.DB().Create(value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
manager := &fakeNftablesManager{reconcileErr: errors.New("ssh unavailable")}
|
||||
h.nftablesManager = manager
|
||||
update := func() response.R {
|
||||
t.Helper()
|
||||
res := postJSONToHandler(t, h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-nft", "status": 0})
|
||||
var out response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if out := update(); out.Code == 0 {
|
||||
t.Error("failed SSH apply returned success")
|
||||
}
|
||||
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 1 {
|
||||
t.Errorf("unconfirmed nftables pause marked completed: %d", got)
|
||||
}
|
||||
if manager.reconcileCount() != 1 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
|
||||
t.Fatalf("did not attempt to remove disabled user's nftables rule: %+v", manager.lastPlan)
|
||||
}
|
||||
manager.reconcileErr = nil
|
||||
if out := update(); out.Code != 0 {
|
||||
t.Fatalf("retry nftables disable: %s", out.Msg)
|
||||
}
|
||||
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 0 {
|
||||
t.Errorf("confirmed nftables pause was not stored: %d", got)
|
||||
}
|
||||
if manager.reconcileCount() != 2 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
|
||||
t.Errorf("retry did not apply a plan without disabled forward: %+v", manager.lastPlan)
|
||||
}
|
||||
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", otherForwardID); got != 1 {
|
||||
t.Errorf("disabled another user's nftables forward: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDisableReportsPauseFailureAndCanRetry(t *testing.T) {
|
||||
for _, offline := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("offline=%t", offline), func(t *testing.T) {
|
||||
a := newCleanupAgent(t)
|
||||
now := time.Now().UnixMilli()
|
||||
userID, err := a.h.repo.CreateUser("disable-retry", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []interface{}{
|
||||
&model.Tunnel{ID: 1, Name: "test", Type: 1, Status: 1, Flow: 1},
|
||||
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||
&model.Forward{ID: 20, UserID: userID, UserName: "disable-retry", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
|
||||
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
|
||||
} {
|
||||
if err := a.h.repo.DB().Create(value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
onlineServer := a.h.wsServer
|
||||
if offline {
|
||||
a.h.wsServer = ws.NewServer(a.h.repo, "test-jwt-secret")
|
||||
} else {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("pause rejected"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Updates(map[string]interface{}{"is_remote": 1, "remote_url": ts.URL, "remote_token": "test-token"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
update := func() response.R {
|
||||
t.Helper()
|
||||
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-retry", "status": 0})
|
||||
var out response.R
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if out := update(); out.Code == 0 {
|
||||
t.Error("pause failure returned success")
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user WHERE id = ?", userID); got != 0 {
|
||||
t.Errorf("failed pause restored account access: %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
|
||||
t.Errorf("failed pause restored tunnel access: %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
|
||||
t.Errorf("unconfirmed pause marked completed, status = %d", got)
|
||||
}
|
||||
a.h.wsServer = onlineServer
|
||||
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Update("is_remote", 0).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := update(); out.Code != 0 {
|
||||
t.Fatalf("retry failed: %s", out.Msg)
|
||||
}
|
||||
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
|
||||
t.Errorf("retry did not pause forward: %d", got)
|
||||
}
|
||||
if len(a.commandsOfType("PauseService")) == 0 {
|
||||
t.Error("retry never sent PauseService")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,8 @@ func shouldSkip(path string) bool {
|
||||
return false
|
||||
case path == "/api/v1/user/login":
|
||||
return true
|
||||
case path == "/api/v1/user/passkey/status" || path == "/api/v1/user/passkey/login/begin" || path == "/api/v1/user/passkey/login/finish":
|
||||
return true
|
||||
case path == "/api/v1/public/config/get":
|
||||
return true
|
||||
case path == "/api/v1/federation/connect":
|
||||
@@ -182,6 +184,8 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
case "/api/v1/license/activate":
|
||||
return true
|
||||
case "/api/v1/announcement/update":
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -197,6 +197,39 @@ func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivateRequiresAdmin(t *testing.T) {
|
||||
if !requiresAdmin("/api/v1/license/activate") {
|
||||
t.Fatal("expected license activation to require admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRejectsNonAdminLicenseActivation(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(2, "regular_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
|
||||
}
|
||||
|
||||
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
|
||||
@@ -6,24 +6,53 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var AccountID string
|
||||
|
||||
type KeygenClient struct {
|
||||
AccountID string
|
||||
Token string
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func (e *APIError) HasCode(code string) bool {
|
||||
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
|
||||
}
|
||||
|
||||
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
|
||||
|
||||
func NewKeygenClient(accountID, token string) *KeygenClient {
|
||||
return &KeygenClient{
|
||||
AccountID: accountID,
|
||||
Token: token,
|
||||
AccountID: accountID,
|
||||
Token: token,
|
||||
BaseURL: defaultAPIBaseURL,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *KeygenClient) apiURL(path string) string {
|
||||
baseURL := strings.TrimRight(c.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultAPIBaseURL
|
||||
}
|
||||
return fmt.Sprintf("%s/accounts/%s/%s", baseURL, c.AccountID, strings.TrimLeft(path, "/"))
|
||||
}
|
||||
|
||||
type ValidateResponse struct {
|
||||
Meta struct {
|
||||
Valid bool `json:"valid"`
|
||||
@@ -35,6 +64,7 @@ type ValidateResponse struct {
|
||||
Expiry string `json:"expiry"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
MachineID string `json:"-"`
|
||||
}
|
||||
|
||||
type ActivateMachineRequest struct {
|
||||
@@ -54,8 +84,37 @@ type ActivateMachineRequest struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type keygenErrorResponse struct {
|
||||
Errors []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type MachineResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func hasKeygenErrorCode(body []byte, code string) bool {
|
||||
var resp keygenErrorResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range resp.Errors {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Code), code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
||||
return c.ValidateKeyWithMachine(key, fingerprint, "")
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
|
||||
url := c.apiURL("licenses/actions/validate-key")
|
||||
|
||||
meta := map[string]interface{}{
|
||||
"key": key,
|
||||
@@ -66,6 +125,14 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
}
|
||||
if machineID != "" {
|
||||
scope, _ := meta["scope"].(map[string]interface{})
|
||||
if scope == nil {
|
||||
scope = make(map[string]interface{})
|
||||
meta["scope"] = scope
|
||||
}
|
||||
scope["machine"] = machineID
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": meta,
|
||||
@@ -91,7 +158,8 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
@@ -102,8 +170,44 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
|
||||
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
|
||||
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
if c.Token != "" {
|
||||
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
|
||||
req.Header.Set("Authorization", "License "+c.Token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var machineResp MachineResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
machineID := strings.TrimSpace(machineResp.Data.ID)
|
||||
if machineID == "" {
|
||||
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
|
||||
}
|
||||
return machineID, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
|
||||
url := c.apiURL("licenses/actions/validate-key")
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": map[string]string{
|
||||
@@ -130,7 +234,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
@@ -141,8 +246,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
|
||||
url := c.apiURL("machines")
|
||||
|
||||
var reqBody ActivateMachineRequest
|
||||
reqBody.Data.Type = "machines"
|
||||
@@ -165,23 +270,24 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
|
||||
// Machine already registered to this license or limit reached because it's already us.
|
||||
// The subsequent ValidateKey check will determine if the existing machine is actually us.
|
||||
return nil
|
||||
var machineResp MachineResponse
|
||||
if json.Unmarshal(body, &machineResp) == nil {
|
||||
return strings.TrimSpace(machineResp.Data.ID), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
|
||||
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
|
||||
// to the target license, so this means the same machine is already bound.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Meta struct {
|
||||
Scope map[string]string `json:"scope"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
|
||||
}
|
||||
if body.Meta.Scope["machine"] != "machine-id" {
|
||||
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "")
|
||||
client.BaseURL = server.URL
|
||||
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
|
||||
if err != nil || !validation.Meta.Valid {
|
||||
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "License license-key" {
|
||||
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
machineID, err := client.GetMachineID("fingerprint")
|
||||
if err != nil || machineID != "machine-id" {
|
||||
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
|
||||
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
|
||||
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
machineID, err := client.ActivateMachine("license-id", "fingerprint")
|
||||
if err != nil || machineID != "machine-id" {
|
||||
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
|
||||
_, err := client.ActivateMachine("license-id", "fingerprint")
|
||||
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
|
||||
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigTunnelQualityProbeIntervalSec = "monitor_tunnel_quality_interval_sec"
|
||||
DefaultTunnelQualityProbeIntervalSec = 1
|
||||
MinTunnelQualityProbeIntervalSec = 1
|
||||
MaxTunnelQualityProbeIntervalSec = 3600
|
||||
)
|
||||
|
||||
func TunnelQualityProbeIntervalSecondsFromConfigMap(cfg map[string]string) int {
|
||||
if cfg == nil {
|
||||
return DefaultTunnelQualityProbeIntervalSec
|
||||
}
|
||||
seconds, err := parseTunnelQualityProbeIntervalSeconds(cfg[ConfigTunnelQualityProbeIntervalSec])
|
||||
if err != nil {
|
||||
return DefaultTunnelQualityProbeIntervalSec
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func NormalizeTunnelQualityProbeIntervalSeconds(value string) (string, error) {
|
||||
seconds, err := parseTunnelQualityProbeIntervalSeconds(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(seconds), nil
|
||||
}
|
||||
|
||||
func parseTunnelQualityProbeIntervalSeconds(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("隧道质量探测间隔不能为空")
|
||||
}
|
||||
seconds, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("隧道质量探测间隔必须是整数")
|
||||
}
|
||||
if seconds < MinTunnelQualityProbeIntervalSec || seconds > MaxTunnelQualityProbeIntervalSec {
|
||||
return 0, fmt.Errorf(
|
||||
"隧道质量探测间隔必须在 %d 到 %d 秒之间",
|
||||
MinTunnelQualityProbeIntervalSec,
|
||||
MaxTunnelQualityProbeIntervalSec,
|
||||
)
|
||||
}
|
||||
return seconds, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package monitoring
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTunnelQualityProbeIntervalSecondsFromConfigMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
want int
|
||||
}{
|
||||
{name: "missing config", cfg: nil, want: DefaultTunnelQualityProbeIntervalSec},
|
||||
{name: "configured", cfg: map[string]string{ConfigTunnelQualityProbeIntervalSec: "15"}, want: 15},
|
||||
{name: "invalid", cfg: map[string]string{ConfigTunnelQualityProbeIntervalSec: "0"}, want: DefaultTunnelQualityProbeIntervalSec},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TunnelQualityProbeIntervalSecondsFromConfigMap(tt.cfg); got != tt.want {
|
||||
t.Fatalf("interval = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTunnelQualityProbeIntervalSeconds(t *testing.T) {
|
||||
for _, value := range []string{"1", "15", "3600"} {
|
||||
if got, err := NormalizeTunnelQualityProbeIntervalSeconds(value); err != nil || got != value {
|
||||
t.Fatalf("normalize %q = %q, %v", value, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "0", "3601", "1.5", "abc"} {
|
||||
if got, err := NormalizeTunnelQualityProbeIntervalSeconds(value); err == nil {
|
||||
t.Fatalf("normalize %q unexpectedly succeeded with %q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CounterSample struct {
|
||||
ForwardID int64
|
||||
Direction string
|
||||
Protocol string
|
||||
Bytes uint64
|
||||
Packets uint64
|
||||
}
|
||||
|
||||
func ParseCounterComment(comment string) (CounterSample, bool) {
|
||||
parts := strings.Split(comment, " ")
|
||||
if len(parts) != 4 || parts[0] != "flvx" {
|
||||
return CounterSample{}, false
|
||||
}
|
||||
if !strings.HasPrefix(parts[1], "forward:") {
|
||||
return CounterSample{}, false
|
||||
}
|
||||
forwardText := strings.TrimPrefix(parts[1], "forward:")
|
||||
forwardID, err := strconv.ParseInt(forwardText, 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return CounterSample{}, false
|
||||
}
|
||||
direction := parts[2]
|
||||
if direction != CounterDirectionToTarget && direction != CounterDirectionFromTarget {
|
||||
return CounterSample{}, false
|
||||
}
|
||||
protocol := parts[3]
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return CounterSample{}, false
|
||||
}
|
||||
return CounterSample{
|
||||
ForwardID: forwardID,
|
||||
Direction: direction,
|
||||
Protocol: protocol,
|
||||
}, true
|
||||
}
|
||||
|
||||
func ParseCounterSamples(raw []byte) ([]CounterSample, error) {
|
||||
var doc nftListTable
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
samples := make([]CounterSample, 0)
|
||||
for _, item := range doc.Nftables {
|
||||
ruleRaw, ok := item["rule"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var rule nftCounterRule
|
||||
if err := json.Unmarshal(ruleRaw, &rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rule.Table != "flvx" || rule.Chain != "forward" {
|
||||
continue
|
||||
}
|
||||
|
||||
sample, ok, err := parseCounterRule(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
type nftListTable struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
|
||||
type nftCounterRule struct {
|
||||
Table string `json:"table"`
|
||||
Chain string `json:"chain"`
|
||||
Comment string `json:"comment"`
|
||||
Expr []map[string]json.RawMessage `json:"expr"`
|
||||
}
|
||||
|
||||
type nftCounter struct {
|
||||
Bytes uint64 `json:"bytes"`
|
||||
Packets uint64 `json:"packets"`
|
||||
}
|
||||
|
||||
func parseCounterRule(rule nftCounterRule) (CounterSample, bool, error) {
|
||||
var (
|
||||
counter nftCounter
|
||||
hasCounter bool
|
||||
comment = rule.Comment
|
||||
)
|
||||
|
||||
for _, expr := range rule.Expr {
|
||||
if rawCounter, ok := expr["counter"]; ok {
|
||||
if err := json.Unmarshal(rawCounter, &counter); err != nil {
|
||||
return CounterSample{}, false, err
|
||||
}
|
||||
hasCounter = true
|
||||
continue
|
||||
}
|
||||
if rawComment, ok := expr["comment"]; ok && strings.TrimSpace(comment) == "" {
|
||||
if err := json.Unmarshal(rawComment, &comment); err != nil {
|
||||
return CounterSample{}, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasCounter {
|
||||
return CounterSample{}, false, nil
|
||||
}
|
||||
|
||||
sample, ok := ParseCounterComment(comment)
|
||||
if !ok {
|
||||
return CounterSample{}, false, nil
|
||||
}
|
||||
sample.Bytes = counter.Bytes
|
||||
sample.Packets = counter.Packets
|
||||
return sample, true, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package nftables
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCounterCommentAcceptsValidToTargetTCP(t *testing.T) {
|
||||
sample, ok := ParseCounterComment("flvx forward:42 to-target tcp")
|
||||
if !ok {
|
||||
t.Fatal("expected comment to parse")
|
||||
}
|
||||
if sample.ForwardID != 42 ||
|
||||
sample.Direction != CounterDirectionToTarget ||
|
||||
sample.Protocol != "tcp" {
|
||||
t.Fatalf("unexpected sample: %+v", sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterCommentRejectsDNAT(t *testing.T) {
|
||||
if sample, ok := ParseCounterComment("flvx forward:42 dnat tcp"); ok {
|
||||
t.Fatalf("expected dnat comment to be rejected, got %+v", sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterSamplesParsesForwardBillableCounters(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"nftables": [
|
||||
{"metainfo": {"json_schema_version": 1}},
|
||||
{"rule": {
|
||||
"family": "inet",
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"handle": 10,
|
||||
"comment": "flvx forward:42 to-target tcp",
|
||||
"expr": [
|
||||
{"match": {"left": {"payload": {"protocol": "ip", "field": "daddr"}}, "op": "==", "right": "198.51.100.20"}},
|
||||
{"counter": {"packets": 7, "bytes": 4096}}
|
||||
]
|
||||
}},
|
||||
{"rule": {
|
||||
"family": "inet",
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"handle": 11,
|
||||
"comment": "flvx forward:42 from-target udp",
|
||||
"expr": [
|
||||
{"counter": {"packets": 9, "bytes": 8192}}
|
||||
]
|
||||
}},
|
||||
{"rule": {
|
||||
"family": "inet",
|
||||
"table": "flvx",
|
||||
"chain": "prerouting",
|
||||
"handle": 12,
|
||||
"comment": "flvx forward:42 dnat tcp",
|
||||
"expr": [
|
||||
{"counter": {"packets": 100, "bytes": 65536}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}`)
|
||||
|
||||
samples, err := ParseCounterSamples(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCounterSamples: %v", err)
|
||||
}
|
||||
if len(samples) != 2 {
|
||||
t.Fatalf("expected 2 samples, got %d: %+v", len(samples), samples)
|
||||
}
|
||||
|
||||
want := []CounterSample{
|
||||
{ForwardID: 42, Direction: CounterDirectionToTarget, Protocol: "tcp", Bytes: 4096, Packets: 7},
|
||||
{ForwardID: 42, Direction: CounterDirectionFromTarget, Protocol: "udp", Bytes: 8192, Packets: 9},
|
||||
}
|
||||
for i := range want {
|
||||
if samples[i] != want[i] {
|
||||
t.Fatalf("sample %d: expected %+v, got %+v", i, want[i], samples[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterSamplesUsesRuleLevelComment(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"nftables": [
|
||||
{"rule": {
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"comment": "flvx forward:77 to-target udp",
|
||||
"expr": [
|
||||
{"counter": {"packets": 3, "bytes": 2048}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}`)
|
||||
|
||||
samples, err := ParseCounterSamples(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCounterSamples: %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("expected 1 sample, got %d: %+v", len(samples), samples)
|
||||
}
|
||||
want := CounterSample{
|
||||
ForwardID: 77,
|
||||
Direction: CounterDirectionToTarget,
|
||||
Protocol: "udp",
|
||||
Bytes: 2048,
|
||||
Packets: 3,
|
||||
}
|
||||
if samples[0] != want {
|
||||
t.Fatalf("expected %+v, got %+v", want, samples[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterSamplesUsesExprLevelComment(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"nftables": [
|
||||
{"rule": {
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"expr": [
|
||||
{"counter": {"packets": 4, "bytes": 3072}},
|
||||
{"comment": "flvx forward:78 from-target tcp"}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}`)
|
||||
|
||||
samples, err := ParseCounterSamples(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCounterSamples: %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("expected 1 sample, got %d: %+v", len(samples), samples)
|
||||
}
|
||||
want := CounterSample{
|
||||
ForwardID: 78,
|
||||
Direction: CounterDirectionFromTarget,
|
||||
Protocol: "tcp",
|
||||
Bytes: 3072,
|
||||
Packets: 4,
|
||||
}
|
||||
if samples[0] != want {
|
||||
t.Fatalf("expected %+v, got %+v", want, samples[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterSamplesMalformedJSONReturnsError(t *testing.T) {
|
||||
if _, err := ParseCounterSamples([]byte(`{"nftables": [`)); err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCounterSamplesMalformedRuleJSONReturnsError(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"nftables": [
|
||||
{"rule": {
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"comment": "flvx forward:42 to-target tcp",
|
||||
"expr": [
|
||||
{"counter": {"packets": "bad", "bytes": 4096}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}`)
|
||||
if _, err := ParseCounterSamples(raw); err == nil {
|
||||
t.Fatal("expected malformed rule JSON error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
runner Runner
|
||||
}
|
||||
|
||||
func NewManager(runner Runner) *Manager {
|
||||
if runner == nil {
|
||||
runner = NewSSHRunner()
|
||||
}
|
||||
return &Manager{runner: runner}
|
||||
}
|
||||
|
||||
func (m *Manager) Test(ctx context.Context, cfg SSHConfig) error {
|
||||
if err := m.ensureInitialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.runner.Test(ctx, cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) Reconcile(ctx context.Context, cfg SSHConfig, plan NodePlan) (ApplyResult, error) {
|
||||
if err := m.ensureInitialized(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
result := ApplyResult{
|
||||
NodeID: plan.NodeID,
|
||||
Script: RenderTable(plan),
|
||||
Hashes: PlanHashes(plan),
|
||||
}
|
||||
if err := m.runner.ApplyScript(ctx, cfg, result.Script); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Clear(ctx context.Context, cfg SSHConfig) error {
|
||||
if err := m.ensureInitialized(); err != nil {
|
||||
return err
|
||||
}
|
||||
script := RenderTable(NodePlan{})
|
||||
return m.runner.ApplyScript(ctx, cfg, script)
|
||||
}
|
||||
|
||||
func (m *Manager) CollectCounters(ctx context.Context, cfg SSHConfig) ([]CounterSample, error) {
|
||||
if err := m.ensureInitialized(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := m.runner.ListTableJSON(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseCounterSamples(raw)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureInitialized() error {
|
||||
if m == nil || m.runner == nil {
|
||||
return errors.New("nftables manager not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRunner struct {
|
||||
scripts []string
|
||||
err error
|
||||
testErr error
|
||||
listJSON []byte
|
||||
listJSONErr error
|
||||
}
|
||||
|
||||
func (f *fakeRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error {
|
||||
f.scripts = append(f.scripts, script)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Test(ctx context.Context, cfg SSHConfig) error {
|
||||
return f.testErr
|
||||
}
|
||||
|
||||
func (f *fakeRunner) ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error) {
|
||||
return f.listJSON, f.listJSONErr
|
||||
}
|
||||
|
||||
func TestManagerReconcileAppliesRenderedScript(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
manager := NewManager(runner)
|
||||
plan := NodePlan{
|
||||
NodeID: 7,
|
||||
Rules: []Rule{{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}},
|
||||
}
|
||||
|
||||
result, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, plan)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
if len(runner.scripts) != 1 {
|
||||
t.Fatalf("expected 1 script, got %d", len(runner.scripts))
|
||||
}
|
||||
if !strings.Contains(runner.scripts[0], `flvx forward:42 dnat tcp`) {
|
||||
t.Fatalf("script missing forward comment:\n%s", runner.scripts[0])
|
||||
}
|
||||
if result.NodeID != 7 || result.Hashes[42] == "" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReconcileReturnsRunnerError(t *testing.T) {
|
||||
runner := &fakeRunner{err: errors.New("ssh failed")}
|
||||
manager := NewManager(runner)
|
||||
|
||||
_, err := manager.Reconcile(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}, NodePlan{NodeID: 7})
|
||||
if !errors.Is(err, runner.err) {
|
||||
t.Fatalf("expected original runner error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerClearAppliesEmptyTable(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
manager := NewManager(runner)
|
||||
|
||||
if err := manager.Clear(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
if len(runner.scripts) != 1 {
|
||||
t.Fatalf("expected 1 script, got %d", len(runner.scripts))
|
||||
}
|
||||
if strings.Contains(runner.scripts[0], "masquerade comment") {
|
||||
t.Fatalf("empty table should not include masquerade:\n%s", runner.scripts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTestPassesThroughRunnerError(t *testing.T) {
|
||||
runner := &fakeRunner{testErr: errors.New("probe failed")}
|
||||
manager := NewManager(runner)
|
||||
|
||||
err := manager.Test(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"})
|
||||
if !errors.Is(err, runner.testErr) {
|
||||
t.Fatalf("expected original runner error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCollectCountersParsesRunnerTableJSON(t *testing.T) {
|
||||
runner := &fakeRunner{listJSON: []byte(`{
|
||||
"nftables": [
|
||||
{"rule": {
|
||||
"family": "inet",
|
||||
"table": "flvx",
|
||||
"chain": "forward",
|
||||
"comment": "flvx forward:77 to-target tcp",
|
||||
"expr": [
|
||||
{"counter": {"packets": 3, "bytes": 2048}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}`)}
|
||||
manager := NewManager(runner)
|
||||
|
||||
samples, err := manager.CollectCounters(context.Background(), SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"})
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCounters: %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("expected 1 sample, got %d: %+v", len(samples), samples)
|
||||
}
|
||||
want := CounterSample{
|
||||
ForwardID: 77,
|
||||
Direction: CounterDirectionToTarget,
|
||||
Protocol: "tcp",
|
||||
Bytes: 2048,
|
||||
Packets: 3,
|
||||
}
|
||||
if samples[0] != want {
|
||||
t.Fatalf("expected %+v, got %+v", want, samples[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMethodsRequireInitializedRunner(t *testing.T) {
|
||||
cfg := SSHConfig{Host: "203.0.113.10", Port: 22, Username: "root"}
|
||||
plan := NodePlan{NodeID: 7}
|
||||
expected := errors.New("nftables manager not initialized")
|
||||
|
||||
var nilManager *Manager
|
||||
if err := nilManager.Test(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from nil manager Test, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := nilManager.Reconcile(context.Background(), cfg, plan); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from nil manager Reconcile, got %v", err)
|
||||
}
|
||||
|
||||
if err := nilManager.Clear(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from nil manager Clear, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := nilManager.CollectCounters(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from nil manager CollectCounters, got %v", err)
|
||||
}
|
||||
|
||||
manager := &Manager{}
|
||||
if err := manager.Test(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from Test, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := manager.Reconcile(context.Background(), cfg, plan); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from Reconcile, got %v", err)
|
||||
}
|
||||
|
||||
if err := manager.Clear(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from Clear, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := manager.CollectCounters(context.Background(), cfg); err == nil || err.Error() != expected.Error() {
|
||||
t.Fatalf("expected not initialized error from CollectCounters, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseSingleTarget(raw string) (Target, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return Target{}, fmt.Errorf("目标地址不能为空")
|
||||
}
|
||||
if strings.Contains(value, ",") || strings.Contains(value, "\n") {
|
||||
return Target{}, fmt.Errorf("nftables 纯转发第一阶段仅支持单目标")
|
||||
}
|
||||
if hasScheme(value) {
|
||||
return Target{}, fmt.Errorf("目标地址必须是 host:port,不能包含 URL scheme")
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(value)
|
||||
if err != nil {
|
||||
return Target{}, fmt.Errorf("目标地址必须是 host:port")
|
||||
}
|
||||
host = strings.TrimSpace(strings.Trim(host, "[]"))
|
||||
if host == "" {
|
||||
return Target{}, fmt.Errorf("目标主机不能为空")
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return Target{}, fmt.Errorf("目标端口必须在 1-65535 之间")
|
||||
}
|
||||
return Target{Host: host, Port: port}, nil
|
||||
}
|
||||
|
||||
func hasScheme(value string) bool {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(value, "://") {
|
||||
return true
|
||||
}
|
||||
colon := strings.IndexByte(value, ':')
|
||||
if colon <= 0 || strings.Contains(parsed.Scheme, ".") {
|
||||
return false
|
||||
}
|
||||
suffix := value[colon+1:]
|
||||
return strings.IndexByte(suffix, ':') == -1
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package nftables
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSingleTargetAcceptsHostPortAndIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
host string
|
||||
port int
|
||||
}{
|
||||
{name: "hostname", raw: "example.com:443", host: "example.com", port: 443},
|
||||
{name: "ipv4", raw: "198.51.100.20:8443", host: "198.51.100.20", port: 8443},
|
||||
{name: "ipv6", raw: "[2001:db8::1]:443", host: "2001:db8::1", port: 443},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target, err := ParseSingleTarget(tt.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSingleTarget: %v", err)
|
||||
}
|
||||
if target.Host != tt.host || target.Port != tt.port {
|
||||
t.Fatalf("expected %s/%d, got %+v", tt.host, tt.port, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSingleTargetRejectsUnsupportedValues(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"",
|
||||
"example.com",
|
||||
"example.com:0",
|
||||
"example.com:65536",
|
||||
"a:1,b:2",
|
||||
"http://example.com:443",
|
||||
"https:443",
|
||||
"mailto:443",
|
||||
} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
if _, err := ParseSingleTarget(raw); err == nil {
|
||||
t.Fatalf("expected error for %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RenderTable(plan NodePlan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("table inet flvx {\n")
|
||||
b.WriteString(" chain prerouting {\n")
|
||||
b.WriteString(" type nat hook prerouting priority dstnat; policy accept;\n")
|
||||
for _, rule := range sortedRules(plan.Rules) {
|
||||
family := nftAddressFamily(rule.TargetHost)
|
||||
dnatFamily := ""
|
||||
if family != "" {
|
||||
dnatFamily = family + " "
|
||||
}
|
||||
for _, protocol := range normalizedProtocols(rule.Protocols) {
|
||||
b.WriteString(fmt.Sprintf(" %s dport %d counter dnat %sto %s comment %q\n",
|
||||
protocol,
|
||||
rule.InPort,
|
||||
dnatFamily,
|
||||
formatDNATTarget(rule.TargetHost, rule.TargetPort),
|
||||
counterComment(rule.ForwardID, CounterDirectionDNAT, protocol),
|
||||
))
|
||||
}
|
||||
}
|
||||
b.WriteString(" }\n\n")
|
||||
b.WriteString(" chain postrouting {\n")
|
||||
b.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n")
|
||||
if len(plan.Rules) > 0 {
|
||||
b.WriteString(" masquerade comment \"flvx masquerade\"\n")
|
||||
}
|
||||
b.WriteString(" }\n\n")
|
||||
b.WriteString(" chain forward {\n")
|
||||
b.WriteString(" type filter hook forward priority filter; policy accept;\n")
|
||||
for _, rule := range sortedRules(plan.Rules) {
|
||||
family := nftAddressFamily(rule.TargetHost)
|
||||
if family == "" {
|
||||
continue
|
||||
}
|
||||
targetHost := strings.Trim(strings.TrimSpace(rule.TargetHost), "[]")
|
||||
for _, protocol := range normalizedProtocols(rule.Protocols) {
|
||||
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n",
|
||||
protocol,
|
||||
rule.InPort,
|
||||
family,
|
||||
targetHost,
|
||||
protocol,
|
||||
rule.TargetPort,
|
||||
counterComment(rule.ForwardID, CounterDirectionToTarget, protocol),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n",
|
||||
protocol,
|
||||
rule.InPort,
|
||||
family,
|
||||
targetHost,
|
||||
protocol,
|
||||
rule.TargetPort,
|
||||
counterComment(rule.ForwardID, CounterDirectionFromTarget, protocol),
|
||||
))
|
||||
}
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func counterComment(forwardID int64, direction, protocol string) string {
|
||||
return fmt.Sprintf("flvx forward:%d %s %s", forwardID, direction, protocol)
|
||||
}
|
||||
|
||||
func RuleHash(rule Rule) string {
|
||||
protocols := normalizedProtocols(rule.Protocols)
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d|%d|%s|%d|%s",
|
||||
rule.ForwardID,
|
||||
rule.InPort,
|
||||
strings.TrimSpace(rule.TargetHost),
|
||||
rule.TargetPort,
|
||||
strings.Join(protocols, ","),
|
||||
)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func PlanHashes(plan NodePlan) map[int64]string {
|
||||
hashes := make(map[int64]string, len(plan.Rules))
|
||||
for _, rule := range plan.Rules {
|
||||
hashes[rule.ForwardID] = RuleHash(rule)
|
||||
}
|
||||
return hashes
|
||||
}
|
||||
|
||||
func sortedRules(rules []Rule) []Rule {
|
||||
out := append([]Rule(nil), rules...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].InPort == out[j].InPort {
|
||||
return out[i].ForwardID < out[j].ForwardID
|
||||
}
|
||||
return out[i].InPort < out[j].InPort
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizedProtocols(protocols []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, 2)
|
||||
for _, protocol := range protocols {
|
||||
p := strings.ToLower(strings.TrimSpace(protocol))
|
||||
if p != "tcp" && p != "udp" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"tcp", "udp"}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func formatDNATTarget(host string, port int) string {
|
||||
trimmed := strings.Trim(strings.TrimSpace(host), "[]")
|
||||
if ip := net.ParseIP(trimmed); ip != nil && ip.To4() == nil {
|
||||
return fmt.Sprintf("[%s]:%d", trimmed, port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", trimmed, port)
|
||||
}
|
||||
|
||||
func nftAddressFamily(host string) string {
|
||||
trimmed := strings.Trim(strings.TrimSpace(host), "[]")
|
||||
ip := net.ParseIP(trimmed)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return "ip6"
|
||||
}
|
||||
return "ip"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTableIncludesDNATAndMasquerade(t *testing.T) {
|
||||
script := RenderTable(NodePlan{
|
||||
NodeID: 10,
|
||||
Rules: []Rule{
|
||||
{
|
||||
ForwardID: 42,
|
||||
InPort: 24000,
|
||||
TargetHost: "198.51.100.20",
|
||||
TargetPort: 443,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expectedParts := []string{
|
||||
"table inet flvx",
|
||||
"type nat hook prerouting priority dstnat; policy accept;",
|
||||
"type nat hook postrouting priority srcnat; policy accept;",
|
||||
"tcp dport 24000 counter dnat ip to 198.51.100.20:443 comment \"flvx forward:42 dnat tcp\"",
|
||||
"udp dport 24000 counter dnat ip to 198.51.100.20:443 comment \"flvx forward:42 dnat udp\"",
|
||||
"masquerade comment \"flvx masquerade\"",
|
||||
}
|
||||
for _, part := range expectedParts {
|
||||
if !strings.Contains(script, part) {
|
||||
t.Fatalf("script missing %q:\n%s", part, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableBracketsIPv6Target(t *testing.T) {
|
||||
script := RenderTable(NodePlan{
|
||||
NodeID: 10,
|
||||
Rules: []Rule{
|
||||
{ForwardID: 42, InPort: 24000, TargetHost: "2001:db8::1", TargetPort: 443, Protocols: []string{"tcp"}},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(script, "dnat ip6 to [2001:db8::1]:443") {
|
||||
t.Fatalf("expected bracketed IPv6 dnat target, got:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableIncludesForwardAccountingCounters(t *testing.T) {
|
||||
plan := NodePlan{
|
||||
NodeID: 7,
|
||||
Rules: []Rule{{
|
||||
ForwardID: 42,
|
||||
InPort: 12345,
|
||||
TargetHost: "198.51.100.20",
|
||||
TargetPort: 443,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := RenderTable(plan)
|
||||
wantLines := []string{
|
||||
`tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"`,
|
||||
`udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`,
|
||||
`meta l4proto udp ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`,
|
||||
`meta l4proto udp ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("RenderTable() missing %q\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableIncludesIPv6ForwardAccountingCounters(t *testing.T) {
|
||||
plan := NodePlan{
|
||||
NodeID: 7,
|
||||
Rules: []Rule{{
|
||||
ForwardID: 43,
|
||||
InPort: 12346,
|
||||
TargetHost: "2001:db8::20",
|
||||
TargetPort: 8443,
|
||||
Protocols: []string{"tcp"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := RenderTable(plan)
|
||||
wantLines := []string{
|
||||
`tcp dport 12346 counter dnat ip6 to [2001:db8::20]:8443 comment "flvx forward:43 dnat tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("RenderTable() missing %q\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableAccountingCountersIncludeOriginalPort(t *testing.T) {
|
||||
plan := NodePlan{
|
||||
NodeID: 7,
|
||||
Rules: []Rule{
|
||||
{ForwardID: 42, InPort: 12345, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp"}},
|
||||
{ForwardID: 43, InPort: 12346, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := RenderTable(plan)
|
||||
wantLines := []string{
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("RenderTable() missing %q\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTablePreservesHostnameDNATAndSkipsAccountingCounters(t *testing.T) {
|
||||
plan := NodePlan{
|
||||
NodeID: 7,
|
||||
Rules: []Rule{{
|
||||
ForwardID: 44,
|
||||
InPort: 12347,
|
||||
TargetHost: "example.com",
|
||||
TargetPort: 9443,
|
||||
Protocols: []string{"tcp"},
|
||||
}},
|
||||
}
|
||||
|
||||
got := RenderTable(plan)
|
||||
want := `tcp dport 12347 counter dnat to example.com:9443 comment "flvx forward:44 dnat tcp"`
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("RenderTable() missing %q\n%s", want, got)
|
||||
}
|
||||
unwantedLines := []string{
|
||||
`dnat ip to example.com`,
|
||||
`ip daddr example.com`,
|
||||
`ip saddr example.com`,
|
||||
}
|
||||
for _, unwanted := range unwantedLines {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("RenderTable() unexpectedly contains %q\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleHashIsStable(t *testing.T) {
|
||||
rule := Rule{ForwardID: 42, InPort: 24000, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}
|
||||
if RuleHash(rule) != RuleHash(rule) {
|
||||
t.Fatalf("expected stable rule hash")
|
||||
}
|
||||
if RuleHash(rule) == RuleHash(Rule{ForwardID: 42, InPort: 24001, TargetHost: "198.51.100.20", TargetPort: 443, Protocols: []string{"tcp", "udp"}}) {
|
||||
t.Fatalf("expected hash to change when port changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleHashIgnoresBindIPWhenRenderingDoesNotUseIt(t *testing.T) {
|
||||
base := Rule{
|
||||
ForwardID: 42,
|
||||
InPort: 24000,
|
||||
TargetHost: "198.51.100.20",
|
||||
TargetPort: 443,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
}
|
||||
withBind := base
|
||||
withBind.BindIP = "192.0.2.10"
|
||||
|
||||
if RuleHash(base) != RuleHash(withBind) {
|
||||
t.Fatalf("expected bind IP to be ignored by hash when it is not rendered")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Runner interface {
|
||||
ApplyScript(ctx context.Context, cfg SSHConfig, script string) error
|
||||
Test(ctx context.Context, cfg SSHConfig) error
|
||||
ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error)
|
||||
}
|
||||
|
||||
type SSHRunner struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewSSHRunner() *SSHRunner {
|
||||
return &SSHRunner{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error {
|
||||
nft := nftBinary(cfg)
|
||||
tableName := fmt.Sprintf("flvx_capability_%d", time.Now().UnixNano())
|
||||
return r.run(ctx, cfg, buildCapabilityCheckCommand(nft, tableName))
|
||||
}
|
||||
|
||||
func buildCapabilityCheckCommand(nft, tableName string) string {
|
||||
script := RenderTable(NodePlan{
|
||||
Rules: []Rule{{
|
||||
ForwardID: 1,
|
||||
InPort: 12345,
|
||||
TargetHost: "192.0.2.1",
|
||||
TargetPort: 443,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
}},
|
||||
})
|
||||
script = strings.Replace(script, "table inet flvx {", "table inet "+tableName+" {", 1)
|
||||
return "set -eu\n" +
|
||||
"command -v nft >/dev/null 2>&1\n" +
|
||||
nft + " --version >/dev/null 2>&1\n" +
|
||||
"tmp=$(mktemp /tmp/flvx-nft-capability-XXXXXX.nft)\n" +
|
||||
"trap 'rm -f \"$tmp\"' EXIT\n" +
|
||||
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
|
||||
"if ! " + nft + " -c -f \"$tmp\"; then\n" +
|
||||
" echo 'nftables cannot validate the generated FLVX rules' >&2\n" +
|
||||
" exit 1\n" +
|
||||
"fi"
|
||||
}
|
||||
|
||||
func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error {
|
||||
command := buildApplyCommand(nftBinary(cfg), script)
|
||||
return r.run(ctx, cfg, command)
|
||||
}
|
||||
|
||||
func buildApplyCommand(nft, script string) string {
|
||||
return "set -eu\n" +
|
||||
"tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft)\n" +
|
||||
"batch=$(mktemp /tmp/flvx-nft-batch-XXXXXX.nft) || { rm -f \"$tmp\"; exit 1; }\n" +
|
||||
"cleanup() {\n" +
|
||||
" rm -f \"$tmp\" \"$batch\"\n" +
|
||||
"}\n" +
|
||||
"trap cleanup EXIT\n" +
|
||||
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
|
||||
"if " + nft + " list table inet flvx >/dev/null 2>&1; then\n" +
|
||||
" { printf '%s\\n' 'delete table inet flvx'; cat \"$tmp\"; } > \"$batch\"\n" +
|
||||
"else\n" +
|
||||
" cp \"$tmp\" \"$batch\"\n" +
|
||||
"fi\n" +
|
||||
"if ! " + nft + " -c -f \"$batch\"; then\n" +
|
||||
" echo 'nftables rule validation failed; active rules were preserved' >&2\n" +
|
||||
" exit 1\n" +
|
||||
"fi\n" +
|
||||
nft + " -f \"$batch\""
|
||||
}
|
||||
|
||||
func (r *SSHRunner) ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error) {
|
||||
return r.runOutput(ctx, cfg, nftBinary(cfg)+" -j list table inet flvx")
|
||||
}
|
||||
|
||||
func (r *SSHRunner) run(ctx context.Context, cfg SSHConfig, command string) error {
|
||||
_, err := r.runOutput(ctx, cfg, command)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SSHRunner) runOutput(ctx context.Context, cfg SSHConfig, command string) ([]byte, error) {
|
||||
timeout := r.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
clientConfig, err := buildSSHClientConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(strings.TrimSpace(cfg.Host), fmt.Sprintf("%d", normalizedSSHPort(cfg.Port)))
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(runCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 连接失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 认证失败: %w", err)
|
||||
}
|
||||
client := ssh.NewClient(sshConn, chans, reqs)
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 会话创建失败: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(command)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("SSH 命令超时: %w", runCtx.Err())
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("远程执行失败: %s: %w", message, err)
|
||||
}
|
||||
return nil, fmt.Errorf("远程执行失败: %w", err)
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildSSHClientConfig(cfg SSHConfig) (*ssh.ClientConfig, error) {
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("SSH 主机不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
return nil, fmt.Errorf("SSH 用户名不能为空")
|
||||
}
|
||||
|
||||
auth, err := authMethods(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, fmt.Errorf("SSH 认证方式不能为空")
|
||||
}
|
||||
|
||||
return &ssh.ClientConfig{
|
||||
User: strings.TrimSpace(cfg.Username),
|
||||
Auth: auth,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 15 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authMethods(cfg SSHConfig) ([]ssh.AuthMethod, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.AuthType)) {
|
||||
case "":
|
||||
if strings.TrimSpace(cfg.PrivateKey) == "" {
|
||||
return nil, fmt.Errorf("SSH 私钥不能为空")
|
||||
}
|
||||
signer, err := parsePrivateKey(cfg.PrivateKey, cfg.Passphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||
case "password":
|
||||
if cfg.Password == "" {
|
||||
return nil, fmt.Errorf("SSH 密码不能为空")
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil
|
||||
case "private_key":
|
||||
if strings.TrimSpace(cfg.PrivateKey) == "" {
|
||||
return nil, fmt.Errorf("SSH 私钥不能为空")
|
||||
}
|
||||
signer, err := parsePrivateKey(cfg.PrivateKey, cfg.Passphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", cfg.AuthType)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePrivateKey(privateKey, passphrase string) (ssh.Signer, error) {
|
||||
if passphrase != "" {
|
||||
signer, err := ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 私钥解析失败: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 私钥解析失败: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func nftCommand(cfg SSHConfig, command string) string {
|
||||
return "sh -lc " + sshQuote(command)
|
||||
}
|
||||
|
||||
func nftBinary(cfg SSHConfig) string {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.SudoMode), "sudo") {
|
||||
return "sudo -n nft"
|
||||
}
|
||||
return "nft"
|
||||
}
|
||||
|
||||
func sshQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
func normalizedSSHPort(port int) int {
|
||||
if port <= 0 {
|
||||
return 22
|
||||
}
|
||||
return port
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildApplyCommandStopsAfterValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
applyMarker := filepath.Join(dir, "applied")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
echo "$*" >> "` + logPath + `"
|
||||
if [ "$1" = "list" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-c" ]; then
|
||||
exit 1
|
||||
fi
|
||||
touch "` + applyMarker + `"
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
command := buildApplyCommand(nftPath, "table inet flvx { }")
|
||||
result := exec.Command("sh", "-c", command)
|
||||
if err := result.Run(); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
if _, err := os.Stat(applyMarker); !os.IsNotExist(err) {
|
||||
t.Fatalf("apply ran after validation failure, stat err=%v", err)
|
||||
}
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake nft calls: %v", err)
|
||||
}
|
||||
if strings.Count(string(calls), "-f ") != 1 {
|
||||
t.Fatalf("expected validation only, got calls:\n%s", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCapabilityCheckCommandValidatesRenderedRulesWithoutApplying(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
echo "$*" >> "` + logPath + `"
|
||||
if [ "$1" = "--version" ] || [ "$1" = "-c" ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
command := strings.Replace(buildCapabilityCheckCommand(nftPath, "flvx_capability_test"), "command -v nft", "command -v "+nftPath, 1)
|
||||
result := exec.Command("sh", "-c", command)
|
||||
if output, err := result.CombinedOutput(); err != nil {
|
||||
t.Fatalf("capability command failed: %v: %s", err, output)
|
||||
}
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake nft calls: %v", err)
|
||||
}
|
||||
if strings.Count(string(calls), "-c -f ") != 1 || strings.Contains(string(calls), "\n-f ") {
|
||||
t.Fatalf("expected one check-only invocation, got calls:\n%s", calls)
|
||||
}
|
||||
if !strings.Contains(command, "table inet flvx_capability_test") || !strings.Contains(command, "meta l4proto tcp ct original proto-dst") {
|
||||
t.Fatalf("capability check does not contain representative rendered rules:\n%s", command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplyCommandUsesAtomicReplacementBatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
batchPath := filepath.Join(dir, "batch.nft")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
if [ "$1" = "list" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-f" ]; then
|
||||
cp "$2" "` + batchPath + `"
|
||||
fi
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
script := "table inet flvx {\n chain forward { }\n}"
|
||||
result := exec.Command("sh", "-c", buildApplyCommand(nftPath, script))
|
||||
if output, err := result.CombinedOutput(); err != nil {
|
||||
t.Fatalf("apply command failed: %v: %s", err, output)
|
||||
}
|
||||
batch, err := os.ReadFile(batchPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied batch: %v", err)
|
||||
}
|
||||
want := "delete table inet flvx\n" + script + "\n"
|
||||
if string(batch) != want {
|
||||
t.Fatalf("atomic batch = %q, want %q", batch, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsDefaultToPrivateKey(t *testing.T) {
|
||||
privateKey := mustGeneratePrivateKey(t)
|
||||
methods, err := authMethods(SSHConfig{PrivateKey: privateKey})
|
||||
if err != nil {
|
||||
t.Fatalf("authMethods: %v", err)
|
||||
}
|
||||
if len(methods) != 1 {
|
||||
t.Fatalf("expected 1 auth method, got %d", len(methods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsDefaultPrivateKeyRequiresKey(t *testing.T) {
|
||||
_, err := authMethods(SSHConfig{})
|
||||
if err == nil || !strings.Contains(err.Error(), "SSH 私钥不能为空") {
|
||||
t.Fatalf("expected private key required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGeneratePrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
block := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
return string(pem.EncodeToMemory(block))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package nftables
|
||||
|
||||
const (
|
||||
ModeAgent = "agent"
|
||||
ModeNftables = "nftables"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusApplied = "applied"
|
||||
StatusError = "error"
|
||||
|
||||
CounterDirectionDNAT = "dnat"
|
||||
CounterDirectionToTarget = "to-target"
|
||||
CounterDirectionFromTarget = "from-target"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
ForwardID int64
|
||||
InPort int
|
||||
BindIP string
|
||||
TargetHost string
|
||||
TargetPort int
|
||||
Protocols []string
|
||||
}
|
||||
|
||||
type NodePlan struct {
|
||||
NodeID int64
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
type SSHConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
AuthType string
|
||||
Password string
|
||||
PrivateKey string
|
||||
Passphrase string
|
||||
SudoMode string
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
NodeID int64
|
||||
Script string
|
||||
Hashes map[int64]string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
// FederationPendingRelease keeps rollback work after a failed remote release.
|
||||
// It is independent of tunnels, which may never have committed or be deleted.
|
||||
type FederationPendingRelease struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
RemoteURL string `gorm:"not null"`
|
||||
RemoteToken string `gorm:"not null"`
|
||||
BindingID string
|
||||
ReservationID string
|
||||
ResourceKey string
|
||||
CreatedTime int64
|
||||
}
|
||||
|
||||
func (FederationPendingRelease) TableName() string { return "federation_pending_release" }
|
||||
@@ -16,6 +16,7 @@ type User struct {
|
||||
RoleID int `gorm:"column:role_id;not null"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
@@ -31,24 +32,26 @@ func (User) TableName() string { return "user" }
|
||||
|
||||
// Forward maps to the "forward" table.
|
||||
type Forward struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"`
|
||||
IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"`
|
||||
ProxyProtocol int `gorm:"column:proxy_protocol;not null;default:0"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"`
|
||||
IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"`
|
||||
ProxyProtocol int `gorm:"column:proxy_protocol;not null;default:0"`
|
||||
ProxyProtocolReceive int `gorm:"column:proxy_protocol_receive;not null;default:0"`
|
||||
ProxyProtocolSend int `gorm:"column:proxy_protocol_send;not null;default:0"`
|
||||
}
|
||||
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
@@ -87,6 +90,7 @@ type Node struct {
|
||||
UDPListenAddr string `gorm:"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
IsRemote int `gorm:"column:is_remote;default:0"`
|
||||
ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"`
|
||||
RemoteURL sql.NullString `gorm:"column:remote_url;type:text"`
|
||||
RemoteToken sql.NullString `gorm:"column:remote_token;type:text"`
|
||||
RemoteConfig sql.NullString `gorm:"column:remote_config;type:text"`
|
||||
@@ -95,6 +99,57 @@ type Node struct {
|
||||
|
||||
func (Node) TableName() string { return "node" }
|
||||
|
||||
type NodeSSHConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex"`
|
||||
Host string `gorm:"type:varchar(255);not null"`
|
||||
Port int `gorm:"not null;default:22"`
|
||||
Username string `gorm:"type:varchar(100);not null"`
|
||||
AuthType string `gorm:"column:auth_type;type:varchar(20);not null"`
|
||||
Password sql.NullString `gorm:"type:text"`
|
||||
PrivateKey sql.NullString `gorm:"column:private_key;type:text"`
|
||||
Passphrase sql.NullString `gorm:"type:text"`
|
||||
SudoMode string `gorm:"column:sudo_mode;type:varchar(20);not null;default:'none'"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (NodeSSHConfig) TableName() string { return "node_ssh_config" }
|
||||
|
||||
type NftRuleBinding struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_nft_rule_binding_forward_node;index"`
|
||||
InPort int `gorm:"column:in_port;not null"`
|
||||
Protocols string `gorm:"type:varchar(20);not null;default:'tcp,udp'"`
|
||||
TargetAddr string `gorm:"column:target_addr;type:text;not null"`
|
||||
BindIP string `gorm:"column:bind_ip;type:text;not null;default:''"`
|
||||
RuleHash string `gorm:"column:rule_hash;type:varchar(128);not null;default:''"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'pending'"`
|
||||
LastError string `gorm:"column:last_error;type:text;not null;default:''"`
|
||||
AppliedTime int64 `gorm:"column:applied_time;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (NftRuleBinding) TableName() string { return "nft_rule_binding" }
|
||||
|
||||
type NftCounterState struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_nft_counter_state_key;index"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null;uniqueIndex:idx_nft_counter_state_key;index"`
|
||||
Protocol string `gorm:"type:varchar(10);not null;uniqueIndex:idx_nft_counter_state_key"`
|
||||
Direction string `gorm:"type:varchar(20);not null;uniqueIndex:idx_nft_counter_state_key"`
|
||||
RuleHash string `gorm:"column:rule_hash;type:varchar(128);not null;default:''"`
|
||||
Bytes int64 `gorm:"not null;default:0"`
|
||||
Packets int64 `gorm:"not null;default:0"`
|
||||
CollectedTime int64 `gorm:"column:collected_time;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
@@ -176,6 +231,7 @@ type UserTunnel struct {
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
Num int `gorm:"not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
@@ -298,23 +354,24 @@ type PeerShare struct {
|
||||
func (PeerShare) TableName() string { return "peer_share" }
|
||||
|
||||
type PeerShareRuntime struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
|
||||
@@ -363,6 +420,7 @@ type UserBackup struct {
|
||||
RoleID int `json:"roleId"`
|
||||
ExpTime int64 `json:"expTime"`
|
||||
Flow int64 `json:"flow"`
|
||||
FlowMiB int64 `json:"flowMiB,omitempty"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
@@ -435,24 +493,26 @@ type ChainTunnelBackup struct {
|
||||
}
|
||||
|
||||
type ForwardBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
IPMaxConn int `json:"ipMaxConn,omitempty"`
|
||||
IPSpeedID *int64 `json:"ipSpeedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
ProxyProtocol int `json:"proxyProtocol"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
IPMaxConn int `json:"ipMaxConn,omitempty"`
|
||||
IPSpeedID *int64 `json:"ipSpeedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
ProxyProtocol int `json:"proxyProtocol"`
|
||||
ProxyProtocolReceive int `json:"proxyProtocolReceive,omitempty"`
|
||||
ProxyProtocolSend int `json:"proxyProtocolSend,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardPortBackup struct {
|
||||
@@ -467,6 +527,7 @@ type UserTunnelBackup struct {
|
||||
SpeedID int64 `json:"speedId,omitempty"`
|
||||
Num int `json:"num"`
|
||||
Flow int64 `json:"flow"`
|
||||
FlowMiB int64 `json:"flowMiB,omitempty"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
@@ -541,19 +602,21 @@ type ImportResult struct {
|
||||
|
||||
// ForwardRecord is a minimal forward view used by control plane and flow policy.
|
||||
type ForwardRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
ProxyProtocol int
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
ProxyProtocol int
|
||||
ProxyProtocolReceive int
|
||||
ProxyProtocolSend int
|
||||
}
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
@@ -602,6 +665,7 @@ type NodeRecord struct {
|
||||
UDPListenAddr string
|
||||
InterfaceName string
|
||||
IsRemote int
|
||||
ForwardMode string
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
RemoteConfig string
|
||||
@@ -647,6 +711,7 @@ type UserTunnelDetail struct {
|
||||
Status int
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
FlowMiB int64 `gorm:"column:flow_mib"`
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
Num int
|
||||
@@ -752,3 +817,23 @@ type TunnelQuality struct {
|
||||
}
|
||||
|
||||
func (TunnelQuality) TableName() string { return "tunnel_quality" }
|
||||
|
||||
// PeerShareResource is the durable desired state for a namespaced peer command.
|
||||
// Rows are retained as tombstones until deletion has been acknowledged.
|
||||
type PeerShareResource struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;uniqueIndex:idx_peer_share_resource_key"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index"`
|
||||
Kind string `gorm:"type:text;not null;uniqueIndex:idx_peer_share_resource_key"`
|
||||
OriginalName string `gorm:"column:original_name;type:text;not null;uniqueIndex:idx_peer_share_resource_key"`
|
||||
RuntimeName string `gorm:"column:runtime_name;type:text;not null;index"`
|
||||
LegacyNames string `gorm:"column:legacy_names;type:text;not null;default:''"`
|
||||
LegacyServiceBase string `gorm:"column:legacy_service_base;type:text;not null;default:''"`
|
||||
ReleaseLegacyFamily bool `gorm:"column:release_legacy_family;not null;default:false"`
|
||||
Config string `gorm:"type:text;not null;default:''"`
|
||||
DesiredState string `gorm:"column:desired_state;type:text;not null;default:'active'"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (PeerShareResource) TableName() string { return "peer_share_resource" }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
// Passkey stores a WebAuthn credential for exactly one panel user.
|
||||
type Passkey struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(512)"`
|
||||
UserID int64 `gorm:"column:user_id;not null;index"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
CredentialJSON string `gorm:"column:credential_json;type:text;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at;not null"`
|
||||
LastUsedAt int64 `gorm:"column:last_used_at;not null;default:0"`
|
||||
}
|
||||
|
||||
func (Passkey) TableName() string { return "passkey" }
|
||||
@@ -14,15 +14,30 @@ var publicConfigKeys = map[string]struct{}{
|
||||
"app_logo": {},
|
||||
"app_favicon": {},
|
||||
"app_bg_image": {},
|
||||
"app_bg_image_light": {},
|
||||
"app_bg_image_dark": {},
|
||||
"cloudflare_site_key": {},
|
||||
"is_commercial": {},
|
||||
"hide_footer_brand": {},
|
||||
}
|
||||
|
||||
var sensitiveConfigKeys = map[string]struct{}{
|
||||
"jwt_secret": {},
|
||||
"license_key": {},
|
||||
"license_expiry": {},
|
||||
"license_machine_id": {},
|
||||
"machine_fingerprint": {},
|
||||
"cloudflare_secret_key": {},
|
||||
}
|
||||
|
||||
var systemManagedConfigKeys = map[string]struct{}{
|
||||
"license_key": {},
|
||||
"license_expiry": {},
|
||||
"license_machine_id": {},
|
||||
"is_commercial": {},
|
||||
"machine_fingerprint": {},
|
||||
}
|
||||
|
||||
func PolicyForConfig(name string) ConfigAccessPolicy {
|
||||
if IsPublicConfigKey(name) {
|
||||
return ConfigAccessPublic
|
||||
@@ -43,6 +58,11 @@ func IsSensitiveConfigKey(name string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsSystemManagedConfigKey(name string) bool {
|
||||
_, ok := systemManagedConfigKeys[normalizeConfigKey(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
@@ -57,6 +77,20 @@ func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func FilterBackupConfigs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for name, value := range in {
|
||||
if IsSensitiveConfigKey(name) || IsSystemManagedConfigKey(name) {
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeConfigKey(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ func TestConfigPolicy(t *testing.T) {
|
||||
{name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic},
|
||||
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
|
||||
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
|
||||
{name: "app_bg_image_light is public", key: "app_bg_image_light", want: ConfigAccessPublic},
|
||||
{name: "app_bg_image_dark is public", key: "app_bg_image_dark", want: ConfigAccessPublic},
|
||||
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
|
||||
{name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic},
|
||||
{name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic},
|
||||
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
|
||||
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
|
||||
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
|
||||
@@ -29,14 +33,14 @@ func TestConfigPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigPolicyHelpers(t *testing.T) {
|
||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"}
|
||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "app_bg_image_light", "app_bg_image_dark", "cloudflare_site_key", "is_commercial", "hide_footer_brand"}
|
||||
for _, key := range publicKeys {
|
||||
if !IsPublicConfigKey(key) {
|
||||
t.Fatalf("expected %q to be public", key)
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"}
|
||||
sensitiveKeys := []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "machine_fingerprint", "cloudflare_secret_key"}
|
||||
for _, key := range sensitiveKeys {
|
||||
if !IsSensitiveConfigKey(key) {
|
||||
t.Fatalf("expected %q to be sensitive", key)
|
||||
@@ -67,3 +71,30 @@ func TestConfigPolicyHelpers(t *testing.T) {
|
||||
t.Fatal("expected cloudflare_secret_key to be filtered out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterBackupConfigsOmitsLicenseState(t *testing.T) {
|
||||
filtered := FilterBackupConfigs(map[string]string{
|
||||
"app_name": "Brand",
|
||||
"license_key": "secret-license",
|
||||
"license_expiry": "never",
|
||||
"license_machine_id": "machine-id",
|
||||
"is_commercial": "true",
|
||||
"machine_fingerprint": "fingerprint",
|
||||
})
|
||||
if len(filtered) != 1 || filtered["app_name"] != "Brand" {
|
||||
t.Fatalf("unexpected backup configs: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemManagedConfigKeys(t *testing.T) {
|
||||
for _, key := range []string{"license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint"} {
|
||||
if !IsSystemManagedConfigKey(key) {
|
||||
t.Fatalf("expected %s to be system managed", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"jwt_secret", "cloudflare_secret_key", "app_name"} {
|
||||
if IsSystemManagedConfigKey(key) {
|
||||
t.Fatalf("did not expect %s to be system managed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *Repository) SavePeerShareResources(items []PeerShareResource) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for i := range items {
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "share_id"}, {Name: "kind"}, {Name: "original_name"}}, DoUpdates: clause.AssignmentColumns([]string{"node_id", "runtime_name", "legacy_names", "legacy_service_base", "release_legacy_family", "config", "desired_state", "applied", "updated_time"})}).Create(&items[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareResource(shareID int64, kind, originalName string) (*PeerShareResource, error) {
|
||||
var item model.PeerShareResource
|
||||
err := r.db.Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, originalName).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListPeerShareResourcesByNode(nodeID int64) ([]PeerShareResource, error) {
|
||||
var items []PeerShareResource
|
||||
err := r.db.Where("node_id = ?", nodeID).Order("id").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPeerShareResourceApplied(shareID int64, kind, name string) error {
|
||||
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, name).Update("applied", 1).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ClearPeerShareResourceLegacyNames(shareID int64, kind, name string) error {
|
||||
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, name).Update("legacy_names", "").Error
|
||||
}
|
||||
|
||||
func (r *Repository) WithPeerShareResourceTransaction(fn func(*Repository) error) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error { return fn(&Repository{db: tx, dbPath: r.dbPath}) })
|
||||
}
|
||||
func (r *Repository) ClearPeerShareResourceLegacyFamily(shareID int64, base string) error {
|
||||
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND legacy_service_base = ?", shareID, base).Update("legacy_service_base", "").Error
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go-backend/internal/store/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A pending release remains active until the agent acknowledges deletion. This
|
||||
// keeps its port reserved even if the control connection is unavailable.
|
||||
func (r *Repository) SetPeerShareRuntimeReleasePending(id int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ? AND status = 1", id).Updates(map[string]interface{}{"release_pending": 1, "updated_time": time.Now().UnixMilli()}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CompletePeerShareRuntimeRelease(id int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{"status": 0, "applied": 0, "release_pending": 0, "updated_time": time.Now().UnixMilli()}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListActivePeerShareRuntimesByNode(nodeID int64) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("node_id = ? AND status = 1", nodeID).Order("release_pending DESC, id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type UserForwardDetail = model.UserForwardDetail
|
||||
type StatisticsFlow = model.StatisticsFlow
|
||||
type Node = model.Node
|
||||
type PeerShare = model.PeerShare
|
||||
type PeerShareResource = model.PeerShareResource
|
||||
type PeerShareRuntime = model.PeerShareRuntime
|
||||
type FederationTunnelBinding = model.FederationTunnelBinding
|
||||
type BackupData = model.BackupData
|
||||
@@ -104,6 +105,19 @@ func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta)
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
return applyFlowUploadDeltasTx(tx, deltas)
|
||||
})
|
||||
}
|
||||
|
||||
func applyFlowUploadDeltasTx(tx *gorm.DB, deltas []FlowUploadCounterDelta) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
forwardTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTunnelTotals := make(map[int64][2]int64, len(deltas))
|
||||
@@ -128,36 +142,34 @@ func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta)
|
||||
}
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, forwardID := range sortedFlowUploadTargetIDs(forwardTotals) {
|
||||
total := forwardTotals[forwardID]
|
||||
if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, forwardID := range sortedFlowUploadTargetIDs(forwardTotals) {
|
||||
total := forwardTotals[forwardID]
|
||||
if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range sortedFlowUploadTargetIDs(userTotals) {
|
||||
total := userTotals[userID]
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userID := range sortedFlowUploadTargetIDs(userTotals) {
|
||||
total := userTotals[userID]
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userTunnelID := range sortedFlowUploadTargetIDs(userTunnelTotals) {
|
||||
total := userTunnelTotals[userTunnelID]
|
||||
if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userTunnelID := range sortedFlowUploadTargetIDs(userTunnelTotals) {
|
||||
total := userTunnelTotals[userTunnelID]
|
||||
if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Open / Close ────────────────────────────────────────────────────
|
||||
@@ -189,7 +201,6 @@ func Open(path string) (*Repository, error) {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("prepare sqlite legacy schema: %w", err)
|
||||
}
|
||||
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||
@@ -269,12 +280,22 @@ func (r *Repository) Close() error {
|
||||
}
|
||||
|
||||
func autoMigrateAll(db *gorm.DB) error {
|
||||
if db.Dialector.Name() == "sqlite" {
|
||||
if err := prepareSQLiteNftablesColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
models := []interface{}{
|
||||
&model.User{},
|
||||
&model.Passkey{},
|
||||
&model.UserQuota{},
|
||||
&model.Forward{},
|
||||
&model.ForwardPort{},
|
||||
&model.Node{},
|
||||
&model.NodeSSHConfig{},
|
||||
&model.NftRuleBinding{},
|
||||
&model.NftCounterState{},
|
||||
&model.SpeedLimit{},
|
||||
&model.StatisticsFlow{},
|
||||
&model.Tunnel{},
|
||||
@@ -290,7 +311,9 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.ViteConfig{},
|
||||
&model.PeerShare{},
|
||||
&model.PeerShareRuntime{},
|
||||
&model.PeerShareResource{},
|
||||
&model.FederationTunnelBinding{},
|
||||
&model.FederationPendingRelease{},
|
||||
&model.Announcement{},
|
||||
&model.SchemaVersion{},
|
||||
&model.NodeMetric{},
|
||||
@@ -405,7 +428,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
if m.HasTable(&model.Forward{}) {
|
||||
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol"} {
|
||||
for _, field := range []string{"MaxConn", "IPMaxConn", "IPSpeedID", "ProxyProtocol", "ProxyProtocolReceive", "ProxyProtocolSend"} {
|
||||
if m.HasColumn(&model.Forward{}, field) {
|
||||
continue
|
||||
}
|
||||
@@ -418,6 +441,21 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareSQLiteNftablesColumns(db *gorm.DB) error {
|
||||
if db == nil || db.Dialector.Name() != "sqlite" {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.Node{}) {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.Node{}, "forward_mode") {
|
||||
if err := db.Exec("ALTER TABLE node ADD COLUMN forward_mode varchar(20) NOT NULL DEFAULT 'agent'").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedData(db *gorm.DB) {
|
||||
var adminCount int64
|
||||
if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 {
|
||||
@@ -439,6 +477,14 @@ func seedData(db *gorm.DB) {
|
||||
|
||||
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
||||
now := time.Now().UnixMilli()
|
||||
for name, value := range map[string]string{
|
||||
"is_commercial": "false",
|
||||
"hide_footer_brand": "false",
|
||||
} {
|
||||
cfg := model.ViteConfig{Name: name, Value: value, Time: now}
|
||||
db.Where("name = ?", name).FirstOrCreate(&cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User Queries ────────────────────────────────────────────────────
|
||||
@@ -566,6 +612,23 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
||||
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertConfigs(values map[string]string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for name, value := range values {
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "time"}),
|
||||
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Announcement Queries ────────────────────────────────────────────
|
||||
|
||||
func (r *Repository) GetAnnouncement() (*model.Announcement, error) {
|
||||
@@ -610,7 +673,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
|
||||
}
|
||||
var items []model.UserTunnelDetail
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.flow_mib, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
|
||||
Where("user_tunnel.user_id = ?", userID).
|
||||
@@ -793,6 +856,28 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
if err := r.db.Order("inx ASC, id ASC").Find(&nodes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodeIDs := make([]int64, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if defaultNodeForwardMode(n.ForwardMode) == "nftables" {
|
||||
nodeIDs = append(nodeIDs, n.ID)
|
||||
}
|
||||
}
|
||||
sshConfigByNodeID := make(map[int64]map[string]interface{}, len(nodeIDs))
|
||||
if len(nodeIDs) > 0 {
|
||||
var configs []model.NodeSSHConfig
|
||||
if err := r.db.Where("node_id IN ?", nodeIDs).Find(&configs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cfg := range configs {
|
||||
sshConfigByNodeID[cfg.NodeID] = map[string]interface{}{
|
||||
"host": cfg.Host,
|
||||
"port": cfg.Port,
|
||||
"username": cfg.Username,
|
||||
"authType": cfg.AuthType,
|
||||
"sudoMode": cfg.SudoMode,
|
||||
}
|
||||
}
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
@@ -810,11 +895,13 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"version": nullableString(n.Version),
|
||||
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
||||
"status": n.Status, "isRemote": n.IsRemote,
|
||||
"forwardMode": defaultNodeForwardMode(n.ForwardMode),
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
"sshConfig": sshConfigByNodeID[n.ID],
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -841,7 +928,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
item := map[string]interface{}{
|
||||
"id": u.ID, "user": u.User, "name": u.User,
|
||||
"roleId": u.RoleID, "status": u.Status,
|
||||
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime,
|
||||
"flow": u.Flow, "flowMiB": u.FlowMiB, "num": u.Num, "expTime": u.ExpTime,
|
||||
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
|
||||
"updatedTime": nullableInt64(u.UpdatedTime),
|
||||
"inFlow": u.InFlow, "outFlow": u.OutFlow,
|
||||
@@ -886,31 +973,33 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
type fwdRow struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
IPSpeedLimitName string
|
||||
ProxyProtocol int
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
IPSpeedLimitName string
|
||||
ProxyProtocol int
|
||||
ProxyProtocolReceive int
|
||||
ProxyProtocolSend int
|
||||
}
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol").
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
@@ -921,6 +1010,7 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(row.ProxyProtocol, row.ProxyProtocolReceive, row.ProxyProtocolSend)
|
||||
inIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -933,9 +1023,11 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||
"maxConn": row.MaxConn,
|
||||
"ipMaxConn": row.IPMaxConn,
|
||||
"proxyProtocol": row.ProxyProtocol,
|
||||
"maxConn": row.MaxConn,
|
||||
"ipMaxConn": row.IPMaxConn,
|
||||
"proxyProtocol": row.ProxyProtocol,
|
||||
"proxyProtocolReceive": proxyProtocolReceive,
|
||||
"proxyProtocolSend": proxyProtocolSend,
|
||||
}
|
||||
if row.SpeedID.Valid {
|
||||
item["speedId"] = row.SpeedID.Int64
|
||||
@@ -1426,7 +1518,12 @@ func (r *Repository) DeletePeerShare(id int64) error {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Where("share_id = ?", id).Delete(&model.PeerShareRuntime{})
|
||||
if err := tx.Where("share_id = ?", id).Delete(&model.PeerShareRuntime{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("share_id = ?", id).Delete(&model.PeerShareResource{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&model.PeerShare{}).Error
|
||||
})
|
||||
}
|
||||
@@ -1519,7 +1616,8 @@ func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error
|
||||
return errors.New("runtime item is nil")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"binding_id": item.BindingID, "role": item.Role,
|
||||
"reservation_id": item.ReservationID,
|
||||
"binding_id": item.BindingID, "role": item.Role,
|
||||
"chain_name": item.ChainName, "service_name": item.ServiceName,
|
||||
"protocol": item.Protocol, "strategy": item.Strategy,
|
||||
"port": item.Port, "target": item.Target,
|
||||
@@ -1667,35 +1765,19 @@ func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(node
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByNode(nodeID int64) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var names []string
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
|
||||
Pluck("service_name", &names).Error
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("node_id = ? AND status = 1 AND role = ?", nodeID, "forward").Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = make([]string, 0)
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
||||
@@ -1904,7 +1986,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = FilterSensitiveConfigs(configs)
|
||||
backup.Configs = FilterBackupConfigs(configs)
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
@@ -1984,7 +2066,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = FilterSensitiveConfigs(v)
|
||||
backup.Configs = FilterBackupConfigs(v)
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
@@ -2006,7 +2088,7 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
||||
for _, u := range users {
|
||||
b := model.UserBackup{
|
||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||
}
|
||||
@@ -2137,8 +2219,10 @@ func (r *Repository) exportForwards() ([]model.ForwardBackup, error) {
|
||||
TunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,
|
||||
InFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: f.ProxyProtocolReceive,
|
||||
ProxyProtocolSend: f.ProxyProtocolSend,
|
||||
}
|
||||
if f.SpeedID.Valid {
|
||||
v := f.SpeedID.Int64
|
||||
@@ -2180,7 +2264,7 @@ func (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {
|
||||
for _, ut := range uts {
|
||||
b := model.UserTunnelBackup{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Num: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
Num: ut.Num, Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,
|
||||
}
|
||||
if ut.SpeedID.Valid {
|
||||
@@ -2377,6 +2461,7 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
RoleID: u.RoleID,
|
||||
ExpTime: u.ExpTime,
|
||||
Flow: u.Flow,
|
||||
FlowMiB: u.FlowMiB,
|
||||
InFlow: u.InFlow,
|
||||
OutFlow: u.OutFlow,
|
||||
FlowResetTime: u.FlowResetTime,
|
||||
@@ -2389,7 +2474,7 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
||||
err = tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
|
||||
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||
}),
|
||||
}).Create(&item).Error
|
||||
@@ -2572,29 +2657,31 @@ func importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int
|
||||
count := 0
|
||||
for _, f := range forwards {
|
||||
item := model.Forward{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
InFlow: f.InFlow,
|
||||
OutFlow: f.OutFlow,
|
||||
CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: now,
|
||||
Status: f.Status,
|
||||
Inx: f.Inx,
|
||||
SpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.SpeedID), Valid: f.SpeedID != nil && *f.SpeedID > 0},
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.IPSpeedID), Valid: f.IPSpeedID != nil && *f.IPSpeedID > 0},
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
InFlow: f.InFlow,
|
||||
OutFlow: f.OutFlow,
|
||||
CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: now,
|
||||
Status: f.Status,
|
||||
Inx: f.Inx,
|
||||
SpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.SpeedID), Valid: f.SpeedID != nil && *f.SpeedID > 0},
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.IPSpeedID), Valid: f.IPSpeedID != nil && *f.IPSpeedID > 0},
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: f.ProxyProtocolReceive,
|
||||
ProxyProtocolSend: f.ProxyProtocolSend,
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user_id", "user_name", "name", "tunnel_id", "remote_addr", "strategy",
|
||||
"in_flow", "out_flow", "updated_time", "status", "inx", "speed_id", "ip_max_conn", "ip_speed_id", "proxy_protocol",
|
||||
"in_flow", "out_flow", "updated_time", "status", "inx", "speed_id", "ip_max_conn", "ip_speed_id", "proxy_protocol", "proxy_protocol_receive", "proxy_protocol_send",
|
||||
}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
@@ -2625,6 +2712,7 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
|
||||
SpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},
|
||||
Num: ut.Num,
|
||||
Flow: ut.Flow,
|
||||
FlowMiB: ut.FlowMiB,
|
||||
InFlow: ut.InFlow,
|
||||
OutFlow: ut.OutFlow,
|
||||
FlowResetTime: ut.FlowResetTime,
|
||||
@@ -2634,7 +2722,7 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user_id", "tunnel_id", "speed_id", "num", "flow", "in_flow", "out_flow",
|
||||
"user_id", "tunnel_id", "speed_id", "num", "flow", "flow_mib", "in_flow", "out_flow",
|
||||
"flow_reset_time", "exp_time", "status",
|
||||
}),
|
||||
}).Create(&item).Error
|
||||
@@ -2772,7 +2860,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6
|
||||
}
|
||||
|
||||
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
||||
configs = FilterSensitiveConfigs(configs)
|
||||
configs = FilterBackupConfigs(configs)
|
||||
count := 0
|
||||
for name, value := range configs {
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
|
||||
@@ -97,6 +97,10 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
||||
seedConfig(t, r, "cloudflare_site_key", "site-key")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-secret")
|
||||
seedConfig(t, r, "license_key", "license-secret")
|
||||
seedConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||
seedConfig(t, r, "license_machine_id", "machine-id")
|
||||
seedConfig(t, r, "is_commercial", "true")
|
||||
seedConfig(t, r, "machine_fingerprint", "machine-fingerprint")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -117,7 +121,7 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
||||
if backup.Configs["cloudflare_site_key"] != "site-key" {
|
||||
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
||||
}
|
||||
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
|
||||
for _, key := range []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint", "cloudflare_secret_key"} {
|
||||
if _, ok := backup.Configs[key]; ok {
|
||||
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
|
||||
}
|
||||
@@ -136,12 +140,20 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
||||
seedConfig(t, r, "app_name", "before")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-before")
|
||||
seedConfig(t, r, "license_key", "license-before")
|
||||
seedConfig(t, r, "license_expiry", "expiry-before")
|
||||
seedConfig(t, r, "license_machine_id", "machine-before")
|
||||
seedConfig(t, r, "is_commercial", "true")
|
||||
seedConfig(t, r, "machine_fingerprint", "fingerprint-before")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
|
||||
backup := &model.BackupData{Configs: map[string]string{
|
||||
"app_name": "after",
|
||||
"jwt_secret": "jwt-after",
|
||||
"license_key": "license-after",
|
||||
"license_expiry": "expiry-after",
|
||||
"license_machine_id": "machine-after",
|
||||
"is_commercial": "false",
|
||||
"machine_fingerprint": "fingerprint-after",
|
||||
"cloudflare_secret_key": "cloudflare-after",
|
||||
}}
|
||||
|
||||
@@ -156,6 +168,10 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
||||
assertConfigValue(t, r, "app_name", "after")
|
||||
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
||||
assertConfigValue(t, r, "license_key", "license-before")
|
||||
assertConfigValue(t, r, "license_expiry", "expiry-before")
|
||||
assertConfigValue(t, r, "license_machine_id", "machine-before")
|
||||
assertConfigValue(t, r, "is_commercial", "true")
|
||||
assertConfigValue(t, r, "machine_fingerprint", "fingerprint-before")
|
||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
}
|
||||
|
||||
|
||||
@@ -44,20 +44,23 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(f.ProxyProtocol, f.ProxyProtocolReceive, f.ProxyProtocolSend)
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: proxyProtocolReceive,
|
||||
ProxyProtocolSend: proxyProtocolSend,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -233,7 +236,8 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
|
||||
Status: n.Status,
|
||||
PortRange: n.Port,
|
||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||
IsRemote: n.IsRemote,
|
||||
IsRemote: n.IsRemote,
|
||||
ForwardMode: defaultNodeForwardMode(n.ForwardMode),
|
||||
}
|
||||
if n.ServerIPV4.Valid {
|
||||
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const FederationBindingPendingRelease = 2
|
||||
|
||||
func (r *Repository) ListTunnelChainNodeIDsTx(tx *gorm.DB, tunnelID int64) ([]int64, error) {
|
||||
var ids []int64
|
||||
err := tx.Model(&model.ChainTunnel{}).Where("tunnel_id = ?", tunnelID).Pluck("node_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListFederationTunnelBindingsForCleanup(tunnelID int64) ([]model.FederationTunnelBinding, error) {
|
||||
var rows []model.FederationTunnelBinding
|
||||
err := r.db.Where("tunnel_id = ? AND status IN ?", tunnelID, []int{1, FederationBindingPendingRelease}).Order("id").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListPendingFederationTunnelBindings() ([]model.FederationTunnelBinding, error) {
|
||||
var rows []model.FederationTunnelBinding
|
||||
err := r.db.Where("status = ?", FederationBindingPendingRelease).Order("id").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *Repository) MarkFederationTunnelBindingPendingRelease(id int64) error {
|
||||
return r.db.Model(&model.FederationTunnelBinding{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"status": FederationBindingPendingRelease, "updated_time": unixMilliNow()}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteFederationTunnelBinding(id int64) error {
|
||||
return r.db.Where("id = ?", id).Delete(&model.FederationTunnelBinding{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) SavePendingFederationRelease(item *model.FederationPendingRelease) error {
|
||||
item.ID = fmt.Sprintf("%x", sha256.Sum256([]byte(item.RemoteURL+"\n"+item.BindingID+"\n"+item.ReservationID+"\n"+item.ResourceKey)))
|
||||
item.CreatedTime = unixMilliNow()
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListPendingFederationReleases() ([]model.FederationPendingRelease, error) {
|
||||
var rows []model.FederationPendingRelease
|
||||
err := r.db.Order("created_time, id").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *Repository) DeletePendingFederationRelease(id string) error {
|
||||
return r.db.Where("id = ?", id).Delete(&model.FederationPendingRelease{}).Error
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// Only retry unfinished operations. Successful desired state must be replayed
|
||||
// on a real reconnect, not every maintenance tick while the agent stays online.
|
||||
func (r *Repository) ListPendingPeerShareNodeIDs() ([]int64, error) {
|
||||
var resourceNodes, runtimeNodes []int64
|
||||
if err := r.db.Model(&model.PeerShareResource{}).Where("applied = 0").Distinct("node_id").Pluck("node_id", &resourceNodes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("status = 1 AND (release_pending <> 0 OR (applied = 0 AND service_name <> '' AND role IN ?))", []string{"middle", "exit"}).
|
||||
Distinct("node_id").Pluck("node_id", &runtimeNodes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[int64]struct{})
|
||||
for _, ids := range [][]int64{resourceNodes, runtimeNodes} {
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]int64, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
type FlowUploadForwardMeta struct {
|
||||
ForwardID int64
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
TunnelID int64
|
||||
TrafficRatio float64
|
||||
TunnelFlow int64
|
||||
@@ -59,6 +61,8 @@ func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]Fl
|
||||
|
||||
type row struct {
|
||||
ForwardID int64 `gorm:"column:forward_id"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
UserTunnelID int64 `gorm:"column:user_tunnel_id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio"`
|
||||
TunnelFlow int64 `gorm:"column:tunnel_flow"`
|
||||
@@ -68,8 +72,9 @@ func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]Fl
|
||||
for _, chunk := range chunkFlowUploadForwardIDs(ids) {
|
||||
var rows []row
|
||||
err := r.db.Table("forward AS f").
|
||||
Select("f.id AS forward_id, f.tunnel_id AS tunnel_id, t.traffic_ratio AS traffic_ratio, t.flow AS tunnel_flow").
|
||||
Select("f.id AS forward_id, f.user_id AS user_id, COALESCE(ut.id, 0) AS user_tunnel_id, f.tunnel_id AS tunnel_id, t.traffic_ratio AS traffic_ratio, t.flow AS tunnel_flow").
|
||||
Joins("LEFT JOIN tunnel t ON t.id = f.tunnel_id").
|
||||
Joins("LEFT JOIN user_tunnel ut ON ut.user_id = f.user_id AND ut.tunnel_id = f.tunnel_id").
|
||||
Where("f.id IN ?", chunk).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
@@ -84,6 +89,8 @@ func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]Fl
|
||||
}
|
||||
out[row.ForwardID] = FlowUploadForwardMeta{
|
||||
ForwardID: row.ForwardID,
|
||||
UserID: row.UserID,
|
||||
UserTunnelID: row.UserTunnelID,
|
||||
TunnelID: row.TunnelID,
|
||||
TrafficRatio: row.TrafficRatio,
|
||||
TunnelFlow: row.TunnelFlow,
|
||||
@@ -113,20 +120,23 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(f.ProxyProtocol, f.ProxyProtocolReceive, f.ProxyProtocolSend)
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: proxyProtocolReceive,
|
||||
ProxyProtocolSend: proxyProtocolSend,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -148,20 +158,23 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(f.ProxyProtocol, f.ProxyProtocolReceive, f.ProxyProtocolSend)
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: proxyProtocolReceive,
|
||||
ProxyProtocolSend: proxyProtocolSend,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -183,20 +196,23 @@ func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]mode
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(f.ProxyProtocol, f.ProxyProtocolReceive, f.ProxyProtocolSend)
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: proxyProtocolReceive,
|
||||
ProxyProtocolSend: proxyProtocolSend,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -219,20 +235,23 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(f.ProxyProtocol, f.ProxyProtocolReceive, f.ProxyProtocolSend)
|
||||
fr := model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
ProxyProtocolReceive: proxyProtocolReceive,
|
||||
ProxyProtocolSend: proxyProtocolSend,
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get metas: %v", err)
|
||||
}
|
||||
if metas[20].TunnelID != 1 || metas[20].TrafficRatio != 2 || metas[20].TunnelFlow != 3 {
|
||||
if metas[20].UserID != 2 || metas[20].UserTunnelID != 10 || metas[20].TunnelID != 1 || metas[20].TrafficRatio != 2 || metas[20].TunnelFlow != 3 {
|
||||
t.Fatalf("unexpected meta for forward 20: %#v", metas[20])
|
||||
}
|
||||
if _, ok := metas[99]; ok {
|
||||
@@ -86,6 +86,99 @@ func TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNftTrafficAccountingAppliesFlowQuotaAndStates(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "nft-accounting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
seedFlowBatchRows(t, r, nowMs)
|
||||
|
||||
quotaViews, err := r.ApplyNftTrafficAccounting(
|
||||
[]FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 480, OutFlow: 660}},
|
||||
map[int64]int64{2: 1140},
|
||||
[]NftCounterStateInput{{
|
||||
NodeID: 11,
|
||||
ForwardID: 20,
|
||||
Protocol: "tcp",
|
||||
Direction: "to-target",
|
||||
RuleHash: "hash-a",
|
||||
Bytes: 1400,
|
||||
Packets: 14,
|
||||
CollectedTime: nowMs,
|
||||
}},
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyNftTrafficAccounting: %v", err)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 480 {
|
||||
t.Fatalf("expected forward in_flow=480, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT out_flow FROM user WHERE id = 2`); got != 660 {
|
||||
t.Fatalf("expected user out_flow=660, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 480 {
|
||||
t.Fatalf("expected user_tunnel in_flow=480, got %d", got)
|
||||
}
|
||||
if quotaViews[2] == nil || quotaViews[2].DailyUsedBytes != 1140 || quotaViews[2].MonthlyUsedBytes != 1140 {
|
||||
t.Fatalf("unexpected quota view: %#v", quotaViews[2])
|
||||
}
|
||||
states, err := r.GetNftCounterStatesByNode(11)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNftCounterStatesByNode: %v", err)
|
||||
}
|
||||
if len(states) != 1 || states[0].ForwardID != 20 || states[0].Bytes != 1400 {
|
||||
t.Fatalf("unexpected nft counter state: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNftTrafficAccountingRollsBackFlowAndQuotaWhenStateWriteFails(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "nft-accounting-rollback.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
seedFlowBatchRows(t, r, nowMs)
|
||||
if err := r.DB().Exec(`DROP TABLE nft_counter_state`).Error; err != nil {
|
||||
t.Fatalf("drop nft_counter_state: %v", err)
|
||||
}
|
||||
|
||||
_, err = r.ApplyNftTrafficAccounting(
|
||||
[]FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 480, OutFlow: 660}},
|
||||
map[int64]int64{2: 1140},
|
||||
[]NftCounterStateInput{{
|
||||
NodeID: 11,
|
||||
ForwardID: 20,
|
||||
Protocol: "tcp",
|
||||
Direction: "to-target",
|
||||
RuleHash: "hash-a",
|
||||
Bytes: 1400,
|
||||
Packets: 14,
|
||||
CollectedTime: nowMs,
|
||||
}},
|
||||
now,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected ApplyNftTrafficAccounting to fail")
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected forward flow rollback, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT out_flow FROM user WHERE id = 2`); got != 0 {
|
||||
t.Fatalf("expected user flow rollback, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT COALESCE((SELECT daily_used_bytes FROM user_quota WHERE user_id = 2), 0)`); got != 0 {
|
||||
t.Fatalf("expected quota rollback, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlowUploadForwardMetasKeepsForwardsWhenTunnelRowMissing(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "flow-batch-missing-tunnel.db"))
|
||||
if err != nil {
|
||||
@@ -106,7 +199,7 @@ func TestGetFlowUploadForwardMetasKeepsForwardsWhenTunnelRowMissing(t *testing.T
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata for forward with missing tunnel row")
|
||||
}
|
||||
if meta.ForwardID != 25 || meta.TunnelID != 99 || meta.TrafficRatio != 1 || meta.TunnelFlow != 1 {
|
||||
if meta.ForwardID != 25 || meta.UserID != 2 || meta.UserTunnelID != 0 || meta.TunnelID != 99 || meta.TrafficRatio != 1 || meta.TunnelFlow != 1 {
|
||||
t.Fatalf("unexpected fallback meta: %#v", meta)
|
||||
}
|
||||
}
|
||||
@@ -167,3 +260,19 @@ func mustFlowBatchCount(t *testing.T, r *Repository, query string, args ...inter
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func seedFlowBatchRows(t *testing.T, r *Repository, now int64) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 't1', 2.0, 1, 'tls', 3, ?, ?, 1, NULL, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:80', 'fifo', 0, 0, ?, ?, 1, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
const originalUpdated int64 = 1000
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES
|
||||
(20, 2, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
|
||||
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
|
||||
`, originalUpdated, originalUpdated).Error; err != nil {
|
||||
t.Fatalf("insert forwards: %v", err)
|
||||
}
|
||||
|
||||
const resetAt int64 = 2000
|
||||
if err := r.ResetForwardFlow(20, resetAt); err != nil {
|
||||
t.Fatalf("ResetForwardFlow: %v", err)
|
||||
}
|
||||
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
|
||||
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
|
||||
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
|
||||
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
|
||||
}
|
||||
|
||||
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
|
||||
var r *Repository
|
||||
if err := r.ResetForwardFlow(20, 2000); err == nil {
|
||||
t.Fatal("expected uninitialized repository error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("query %q: %v", query, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("query %q returned %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestForwardRepositoryPersistsPerIPLimits(t *testing.T) {
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, []int64{3}, 24000, "", nil, 0, 5, int64(21), 0)
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, []int64{3}, 24000, "", nil, 0, 5, int64(21), 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForwardTx: %v", err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestForwardRepositoryPersistsPerIPLimits(t *testing.T) {
|
||||
t.Fatalf("expected created ipSpeedId 21, got %+v", record.IPSpeedID)
|
||||
}
|
||||
|
||||
if err := r.UpdateForward(forwardID, "per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 9, int64(22), 0); err != nil {
|
||||
if err := r.UpdateForward(forwardID, "per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 9, int64(22), 0, 0, 0); err != nil {
|
||||
t.Fatalf("UpdateForward: %v", err)
|
||||
}
|
||||
record, err = r.GetForwardRecord(forwardID)
|
||||
@@ -216,6 +216,83 @@ func TestForwardRepositoryPersistsPerIPLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRepositoryPersistsProxyProtocolReceiveAndSend(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "proxy-protocol-forward", 2, "1.1.1.1:443", "fifo", now, 1, []int64{3}, 24000, "", nil, 0, 0, nil, 0, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForwardTx: %v", err)
|
||||
}
|
||||
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord after create: %v", err)
|
||||
}
|
||||
if record.ProxyProtocolReceive != 1 || record.ProxyProtocolSend != 2 {
|
||||
t.Fatalf("expected proxyProtocol receive/send 1/2 after create, got %d/%d", record.ProxyProtocolReceive, record.ProxyProtocolSend)
|
||||
}
|
||||
|
||||
if err := r.UpdateForward(forwardID, "proxy-protocol-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 0, nil, 0, 2, 1); err != nil {
|
||||
t.Fatalf("UpdateForward: %v", err)
|
||||
}
|
||||
record, err = r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord after update: %v", err)
|
||||
}
|
||||
if record.ProxyProtocolReceive != 2 || record.ProxyProtocolSend != 1 {
|
||||
t.Fatalf("expected proxyProtocol receive/send 2/1 after update, got %d/%d", record.ProxyProtocolReceive, record.ProxyProtocolSend)
|
||||
}
|
||||
|
||||
records, err := r.ListForwardsByTunnel(2)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForwardsByTunnel: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 listed record, got %d", len(records))
|
||||
}
|
||||
if records[0].ProxyProtocolReceive != 2 || records[0].ProxyProtocolSend != 1 {
|
||||
t.Fatalf("expected listed proxyProtocol receive/send 2/1, got %d/%d", records[0].ProxyProtocolReceive, records[0].ProxyProtocolSend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRepositoryMapsLegacyProxyProtocolToSend(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 2,
|
||||
UserName: "user",
|
||||
Name: "legacy-proxy-protocol-forward",
|
||||
TunnelID: 9,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
ProxyProtocol: 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create legacy forward: %v", err)
|
||||
}
|
||||
forwardID := mustRepoLastInsertID(t, r)
|
||||
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord: %v", err)
|
||||
}
|
||||
if record.ProxyProtocolReceive != 0 || record.ProxyProtocolSend != 2 {
|
||||
t.Fatalf("expected legacy proxyProtocol to map to receive/send 0/2, got %d/%d", record.ProxyProtocolReceive, record.ProxyProtocolSend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackForwardFieldsRestoresPerIPLimits(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
@@ -224,15 +301,15 @@ func TestRollbackForwardFieldsRestoresPerIPLimits(t *testing.T) {
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, nil, 0, "", nil, 7, 5, int64(21), 2)
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, nil, 0, "", nil, 7, 5, int64(21), 2, 0, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForwardTx: %v", err)
|
||||
}
|
||||
if err := r.UpdateForward(forwardID, "rollback-per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 0, nil, 0); err != nil {
|
||||
if err := r.UpdateForward(forwardID, "rollback-per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 0, nil, 0, 0, 0); err != nil {
|
||||
t.Fatalf("UpdateForward: %v", err)
|
||||
}
|
||||
|
||||
r.RollbackForwardFields(forwardID, 1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", 1, nil, 7, 5, int64(21), 2, now+2)
|
||||
r.RollbackForwardFields(forwardID, 1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", 1, nil, 7, 5, int64(21), 2, 0, 2, now+2)
|
||||
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
|
||||
@@ -132,3 +132,31 @@ func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
|
||||
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestTunnelQualitiesIncludesChainDetails(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.InsertTunnelQuality(&model.TunnelQuality{
|
||||
TunnelID: 7,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Success: 1,
|
||||
ChainDetails: `{"primaryPath":[],"candidateHops":[{"fromNodeId":10,"toNodeId":31}]}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tunnel quality: %v", err)
|
||||
}
|
||||
|
||||
items, err := r.GetLatestTunnelQualities()
|
||||
if err != nil {
|
||||
t.Fatalf("get latest tunnel qualities: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one latest tunnel quality, got %+v", items)
|
||||
}
|
||||
if items[0].ChainDetails == "" {
|
||||
t.Fatalf("expected chain details in latest quality row, got %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user