mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e85234612 | |||
| b26e9ee9e4 | |||
| 577c189737 | |||
| 3209ff8a59 | |||
| 7e81e1c65c | |||
| a784350eaf | |||
| 743bf6f278 | |||
| fadc2b6c12 | |||
| 46bfb86c1b | |||
| 3549fe5ad8 | |||
| dd3ed4e284 | |||
| c794586cc7 | |||
| d9303032ca | |||
| 6da8b2b6ec |
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20.19.0'
|
node-version-file: '.node-version'
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
run: npm install -g pnpm@10.28.1
|
run: npm install -g pnpm@10.28.1
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: 'go-backend/go.mod'
|
||||||
cache-dependency-path: go-backend/go.sum
|
cache-dependency-path: go-backend/go.sum
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
@@ -63,6 +63,14 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
|
||||||
|
- name: Test repository and SQLite upgrade migrations
|
||||||
|
run: go test ./internal/store/repo -count=1
|
||||||
|
|
||||||
|
- name: Test panel upgrade lifecycle and permissions
|
||||||
|
run: |
|
||||||
|
go test ./internal/panelupgrade -count=1
|
||||||
|
go test ./internal/http/handler ./tests/contract -run 'SystemUpgrade|SelectComposeAsset|DownloadReleaseAsset|UpgradeBackup|RestoreBackup' -count=1
|
||||||
|
|
||||||
backend-postgres-contract:
|
backend-postgres-contract:
|
||||||
name: Go Backend PostgreSQL Contract
|
name: Go Backend PostgreSQL Contract
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -89,7 +97,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: 'go-backend/go.mod'
|
||||||
cache-dependency-path: go-backend/go.sum
|
cache-dependency-path: go-backend/go.sum
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
@@ -112,7 +120,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: 'go-gost/go.mod'
|
||||||
cache-dependency-path: go-gost/go.sum
|
cache-dependency-path: go-gost/go.sum
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: 'go-gost/go.mod'
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
go-version-file: 'go-backend/go.mod'
|
||||||
|
|
||||||
- name: Cache Go dependencies
|
- name: Cache Go dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.node-version'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
- name: Get version from tag
|
- 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.
|
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
|
||||||
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
|
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
|
||||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
- **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
|
## Anti-patterns
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,35 @@
|
|||||||
- 支持隧道修改配置、转发修改隧道
|
- 支持隧道修改配置、转发修改隧道
|
||||||
|
|
||||||
|
|
||||||
|
## 构建环境
|
||||||
|
|
||||||
|
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)。
|
||||||
|
|
||||||
## 部署流程
|
## 部署流程
|
||||||
---
|
---
|
||||||
|
### 让 AI Agent 帮你安装(复制即用)
|
||||||
|
|
||||||
|
将下面整段提示词复制给能执行终端命令的 AI Agent(如 Codex、Claude Code)。Agent 已运行在目标 Linux 服务器上时可直接安装;远程安装时,提供 SSH 主机别名或连接方式,使用已有 SSH 密钥登录即可,无需把私钥贴进对话。
|
||||||
|
|
||||||
|
```text
|
||||||
|
请帮我在目标 Linux 服务器上安装 FLVX 流量转发面板,直接执行安装并完成验证。
|
||||||
|
|
||||||
|
项目仓库:https://github.com/Sagit-chu/flvx
|
||||||
|
安装文档:https://github.com/Sagit-chu/flvx/blob/main/doc/install.md
|
||||||
|
|
||||||
|
1. 确认目标服务器:如果当前终端就在目标 Linux 服务器上,直接使用当前环境;否则使用我提供的 SSH 连接。缺少连接信息时,只询问必要信息,确认目标后再执行安装。
|
||||||
|
2. 先阅读项目 README、安装文档及将要执行的安装脚本,检查系统、架构、root/sudo 权限、Docker、Compose、端口占用和已有 FLVX 容器、数据卷及部署文件。已有部署时先确认是更新还是新装,保留原配置和数据。
|
||||||
|
3. 新安装默认使用仓库标记为 latest 的 Release、Docker Compose、SQLite,部署目录 /opt/flvx-panel,前端端口 6366、后端端口 6365。我已指定的版本、目录或端口优先;端口冲突时先与我确认替代端口。
|
||||||
|
4. 缺少依赖时按该发行版的官方方式安装 Docker Engine 和 Compose 插件。进入部署目录,从项目 Releases 下载所选版本的 panel_install.sh,先检查下载成功和脚本内容,再执行。使用交互式终端操作菜单,选择“安装面板”并按上述配置输入;以脚本实际提示为准。
|
||||||
|
5. 默认直连 GitHub,可设置 PROXY_ENABLED=false;连接失败时说明原因,再与我确认是否使用代理。脚本可能修改 Docker IPv6 配置并重启 Docker;若会影响现有服务,先说明影响并确认。保留现有防火墙和 SSH 规则,仅按需放行实际使用的面板端口;云安全组无法操作时列出需要我放行的端口。
|
||||||
|
6. 安装后检查 Compose 服务状态、后端健康检查、前端 HTTP 页面及后端 /flow/test 的响应,并从实际访问端验证连通性。失败时查看相关日志并修复后重试;受网络或权限限制无法完成的检查要明确说明,不能直接宣称安装成功。
|
||||||
|
7. 最后给我实际安装版本、部署目录、访问地址、验证结果,以及在该目录查看状态、查看日志和重启服务的命令。说明默认管理员账号和密码均为 admin_user,提醒首次登录立即修改密码;不要输出 .env 中的 JWT_SECRET、数据库密码等密钥。
|
||||||
|
```
|
||||||
|
|
||||||
|
需要安装转发节点时,告诉 Agent 按[节点端部署文档](doc/install.md#二节点端部署)操作,并提供节点服务器、面板后端地址及从面板获取的节点接入密钥;安装完成后以服务正常运行且面板显示节点在线为准。
|
||||||
|
|
||||||
### Docker Compose部署
|
### Docker Compose部署
|
||||||
#### 快速部署(安装最新版)
|
#### 快速部署(安装最新版)
|
||||||
面板端:
|
面板端:
|
||||||
@@ -73,25 +100,42 @@ docker compose up -d
|
|||||||
|
|
||||||
#### 通行证密钥登录(可选)
|
#### 通行证密钥登录(可选)
|
||||||
|
|
||||||
通行证密钥登录不依赖 Cloudflare Turnstile,但必须在验证码可用时**预先绑定**。原密码登录保持可用;未绑定密钥的用户无法借此绕过验证码。
|
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
|
||||||
|
|
||||||
1. 在面板后端进程的环境中设置可信的、浏览器实际访问的公开源。例如在 Compose 的 `.env` 中添加:
|
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
|
||||||
|
|
||||||
|
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
|
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
该值必须是完整的 HTTPS origin(协议、域名和可选端口),不能带路径、查询参数或尾部 `/`。仅本机开发允许 `http://localhost:3000` 或 `http://127.0.0.1:3000`。在反向代理后部署时填浏览器地址栏中的**前端**公开源,而不是容器内部地址、后端监听地址或代理转发头。API 可以位于另一地址;WebAuthn 验证的是发起操作的前端页面 origin。Compose 模板会将变量传给后端容器;自行部署时需传给 `paneld` 进程。
|
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
|
||||||
|
|
||||||
2. 使环境变量对后端生效(Compose 部署可运行 `docker compose up -d backend`)。登录页出现“使用通行证密钥登录”按钮时表示浏览器支持该功能且配置有效。
|
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
|
||||||
|
|
||||||
3. 用户先照常用密码登录,在“个人”页面的“通行证密钥”卡片中输入**当前密码**、可选名称,然后选择“绑定通行证密钥”并完成设备解锁。建议保留至少一种可用的密码恢复途径。绑定或删除密钥均需当前密码;只有该账号能列出和删除自己的密钥。
|
```yaml
|
||||||
|
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
|
||||||
|
```
|
||||||
|
|
||||||
4. 以后在登录页输入用户名,选择“使用通行证密钥登录”,通过设备解锁即可进入面板。账号被停用时密钥登录也会被拒绝。需要撤销设备时,在个人页面输入当前密码并删除对应密钥。
|
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
|
||||||
|
|
||||||
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
|
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
|
||||||
|
|
||||||
数据库级备份会保留密钥记录。当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
|
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
|
#### 从 SQLite 迁移到 PostgreSQL
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
WORKDIR /src
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ import (
|
|||||||
|
|
||||||
"go-backend/internal/app"
|
"go-backend/internal/app"
|
||||||
"go-backend/internal/config"
|
"go-backend/internal/config"
|
||||||
|
"go-backend/internal/panelupgrade"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) == 3 && os.Args[1] == "panel-upgrade-helper" {
|
||||||
|
if panelupgrade.RunHelper(os.Args[2]) != nil {
|
||||||
|
log.Print("panel upgrade helper stopped; inspect durable job status")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
cfg := config.FromEnv()
|
cfg := config.FromEnv()
|
||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
log.Println("warning: JWT_SECRET is empty")
|
log.Println("warning: JWT_SECRET is empty")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
module go-backend
|
module go-backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package client
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,17 +15,18 @@ type FederationClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RemoteNodeInfo struct {
|
type RemoteNodeInfo struct {
|
||||||
ShareID int64 `json:"shareId"`
|
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||||
ShareName string `json:"shareName"`
|
ShareID int64 `json:"shareId"`
|
||||||
NodeID int64 `json:"nodeId"`
|
ShareName string `json:"shareName"`
|
||||||
NodeName string `json:"nodeName"`
|
NodeID int64 `json:"nodeId"`
|
||||||
ServerIP string `json:"serverIp"`
|
NodeName string `json:"nodeName"`
|
||||||
Status int `json:"status"`
|
ServerIP string `json:"serverIp"`
|
||||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
Status int `json:"status"`
|
||||||
CurrentFlow int64 `json:"currentFlow"`
|
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||||
ExpiryTime int64 `json:"expiryTime"`
|
CurrentFlow int64 `json:"currentFlow"`
|
||||||
PortRangeStart int `json:"portRangeStart"`
|
ExpiryTime int64 `json:"expiryTime"`
|
||||||
PortRangeEnd int `json:"portRangeEnd"`
|
PortRangeStart int `json:"portRangeStart"`
|
||||||
|
PortRangeEnd int `json:"portRangeEnd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoteTunnelResponse struct {
|
type RemoteTunnelResponse struct {
|
||||||
@@ -38,30 +40,34 @@ type RuntimeReservePortRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeReservePortResponse struct {
|
type RuntimeReservePortResponse struct {
|
||||||
ReservationID string `json:"reservationId"`
|
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||||
BindingID string `json:"bindingId"`
|
ReservationID string `json:"reservationId"`
|
||||||
AllocatedPort int `json:"allocatedPort"`
|
BindingID string `json:"bindingId"`
|
||||||
|
AllocatedPort int `json:"allocatedPort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeTarget struct {
|
type RuntimeTarget struct {
|
||||||
Host string `json:"host"`
|
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||||
Port int `json:"port"`
|
Host string `json:"host"`
|
||||||
Protocol string `json:"protocol"`
|
Port int `json:"port"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeApplyRoleRequest struct {
|
type RuntimeApplyRoleRequest struct {
|
||||||
ReservationID string `json:"reservationId"`
|
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||||
ResourceKey string `json:"resourceKey"`
|
ReservationID string `json:"reservationId"`
|
||||||
Role string `json:"role"`
|
ResourceKey string `json:"resourceKey"`
|
||||||
Protocol string `json:"protocol"`
|
Role string `json:"role"`
|
||||||
Strategy string `json:"strategy"`
|
Protocol string `json:"protocol"`
|
||||||
Targets []RuntimeTarget `json:"targets"`
|
Strategy string `json:"strategy"`
|
||||||
|
Targets []RuntimeTarget `json:"targets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeApplyRoleResponse struct {
|
type RuntimeApplyRoleResponse struct {
|
||||||
BindingID string `json:"bindingId"`
|
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||||
ReservationID string `json:"reservationId"`
|
BindingID string `json:"bindingId"`
|
||||||
AllocatedPort int `json:"allocatedPort"`
|
ReservationID string `json:"reservationId"`
|
||||||
|
AllocatedPort int `json:"allocatedPort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeReleaseRoleRequest struct {
|
type RuntimeReleaseRoleRequest struct {
|
||||||
@@ -247,6 +253,9 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
|
if reqData.RelayAuth != nil {
|
||||||
|
return nil, fmt.Errorf("remote authenticated relay apply failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
@@ -260,9 +269,16 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if res.Code != 0 {
|
if res.Code != 0 {
|
||||||
|
if reqData.RelayAuth != nil {
|
||||||
|
return nil, fmt.Errorf("remote authenticated relay apply failed: code %d", res.Code)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reqData.RelayAuth != nil && res.Data.RelayAuthVersion != relaysecurity.Version {
|
||||||
|
return nil, fmt.Errorf("remote did not acknowledge required relay authentication")
|
||||||
|
}
|
||||||
|
|
||||||
return &res.Data, nil
|
return &res.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -534,7 +534,22 @@ func buildForwardServiceDeleteNames(bases []string) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type forwardServiceControlOptions struct {
|
||||||
|
tolerateNotFound bool
|
||||||
|
tolerateOffline bool
|
||||||
|
allowSelfHeal bool
|
||||||
|
continueOnError bool
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
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 {
|
if h == nil || forward == nil {
|
||||||
return errors.New("invalid forward control context")
|
return errors.New("invalid forward control context")
|
||||||
}
|
}
|
||||||
@@ -561,6 +576,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
|||||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||||
|
var nodeErrors []error
|
||||||
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||||
seen := map[int64]struct{}{}
|
seen := map[int64]struct{}{}
|
||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
@@ -569,16 +585,20 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
|||||||
}
|
}
|
||||||
seen[fp.NodeID] = struct{}{}
|
seen[fp.NodeID] = struct{}{}
|
||||||
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
|
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
|
||||||
if isNodeOfflineOrTimeoutError(err) {
|
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tolerateNotFound && isNotFoundError(err) {
|
if opts.tolerateNotFound && isNotFoundError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.continueOnError {
|
||||||
|
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return errors.Join(nodeErrors...)
|
||||||
}
|
}
|
||||||
seen := map[int64]struct{}{}
|
seen := map[int64]struct{}{}
|
||||||
healed := false
|
healed := false
|
||||||
@@ -588,37 +608,50 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
|||||||
}
|
}
|
||||||
seen[fp.NodeID] = struct{}{}
|
seen[fp.NodeID] = struct{}{}
|
||||||
|
|
||||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
nodeErr := func() error {
|
||||||
if err != nil {
|
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if nodeHandled {
|
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
|
||||||
continue
|
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) {
|
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
|
||||||
@@ -1939,7 +1972,12 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
|||||||
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
|
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
|
||||||
}
|
}
|
||||||
if tunnel != nil && tunnel.Type == 2 {
|
if tunnel != nil && tunnel.Type == 2 {
|
||||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
handlerConfig := service["handler"].(map[string]interface{})
|
||||||
|
handlerConfig["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||||
|
if handlerConfig["metadata"] == nil {
|
||||||
|
handlerConfig["metadata"] = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
handlerConfig["metadata"].(map[string]interface{})["chain.strict"] = true
|
||||||
}
|
}
|
||||||
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||||
serviceMetadata := ensureServiceMetadata(service)
|
serviceMetadata := ensureServiceMetadata(service)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder)
|
||||||
|
}
|
||||||
|
func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder)
|
||||||
|
}
|
||||||
|
func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.Err(400, "请求方法错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
ExpectedIDs []int64 `json:"expectedIds"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(400, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := save(req.IDs, req.ExpectedIDs); err != nil {
|
||||||
|
code := 500
|
||||||
|
if errors.Is(err, repo.ErrInvalidDisplayOrder) {
|
||||||
|
code = 400
|
||||||
|
}
|
||||||
|
if errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||||
|
code = 409
|
||||||
|
}
|
||||||
|
msg := "排序保存失败,请刷新后重试"
|
||||||
|
if code != 500 {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.Err(code, msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
"go-backend/internal/store/repo"
|
"go-backend/internal/store/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,18 +69,20 @@ type federationRuntimeReservePortRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type federationRuntimeTarget struct {
|
type federationRuntimeTarget struct {
|
||||||
Host string `json:"host"`
|
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||||
Port int `json:"port"`
|
Host string `json:"host"`
|
||||||
Protocol string `json:"protocol"`
|
Port int `json:"port"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type federationRuntimeApplyRoleRequest struct {
|
type federationRuntimeApplyRoleRequest struct {
|
||||||
ReservationID string `json:"reservationId"`
|
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
|
||||||
ResourceKey string `json:"resourceKey"`
|
ReservationID string `json:"reservationId"`
|
||||||
Role string `json:"role"`
|
ResourceKey string `json:"resourceKey"`
|
||||||
Protocol string `json:"protocol"`
|
Role string `json:"role"`
|
||||||
Strategy string `json:"strategy"`
|
Protocol string `json:"protocol"`
|
||||||
Targets []federationRuntimeTarget `json:"targets"`
|
Strategy string `json:"strategy"`
|
||||||
|
Targets []federationRuntimeTarget `json:"targets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type federationRuntimeReleaseRoleRequest struct {
|
type federationRuntimeReleaseRoleRequest struct {
|
||||||
@@ -115,6 +118,13 @@ func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protoco
|
|||||||
connector := map[string]interface{}{
|
connector := map[string]interface{}{
|
||||||
"type": "relay",
|
"type": "relay",
|
||||||
}
|
}
|
||||||
|
if target.RelayAuth != nil {
|
||||||
|
if err := target.RelayAuth.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connector["auth"] = target.RelayAuth.Config()
|
||||||
|
}
|
||||||
|
|
||||||
if isTCPTunnelProtocol(targetProtocol) {
|
if isTCPTunnelProtocol(targetProtocol) {
|
||||||
connector["metadata"] = map[string]interface{}{
|
connector["metadata"] = map[string]interface{}{
|
||||||
"nodelay": true,
|
"nodelay": true,
|
||||||
@@ -221,7 +231,7 @@ type remoteUsageNodeItem struct {
|
|||||||
SyncError string `json:"syncError,omitempty"`
|
SyncError string `json:"syncError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
|
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string, credentials *relaysecurity.Credentials) map[string]interface{} {
|
||||||
service := map[string]interface{}{
|
service := map[string]interface{}{
|
||||||
"name": serviceName,
|
"name": serviceName,
|
||||||
"addr": addr,
|
"addr": addr,
|
||||||
@@ -230,6 +240,10 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
|
|||||||
},
|
},
|
||||||
"listener": buildTunnelListenerConfig(protocol),
|
"listener": buildTunnelListenerConfig(protocol),
|
||||||
}
|
}
|
||||||
|
if credentials != nil {
|
||||||
|
service["handler"].(map[string]interface{})["auth"] = credentials.Config()
|
||||||
|
}
|
||||||
|
|
||||||
if isTCPTunnelProtocol(protocol) {
|
if isTCPTunnelProtocol(protocol) {
|
||||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||||
"nodelay": true,
|
"nodelay": true,
|
||||||
@@ -243,6 +257,13 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if role == "middle" {
|
if role == "middle" {
|
||||||
|
handler := service["handler"].(map[string]interface{})
|
||||||
|
md, _ := handler["metadata"].(map[string]interface{})
|
||||||
|
if md == nil {
|
||||||
|
md = map[string]interface{}{}
|
||||||
|
handler["metadata"] = md
|
||||||
|
}
|
||||||
|
md["chain.strict"] = true
|
||||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||||
if targetCount > 1 {
|
if targetCount > 1 {
|
||||||
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
|
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
|
||||||
@@ -915,17 +936,18 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
"shareId": share.ID,
|
"shareId": share.ID,
|
||||||
"shareName": share.Name,
|
"relayAuthVersion": relaysecurity.Version,
|
||||||
"nodeId": share.NodeID,
|
"shareName": share.Name,
|
||||||
"nodeName": nodeInfo.Name,
|
"nodeId": share.NodeID,
|
||||||
"serverIp": nodeInfo.ServerIP,
|
"nodeName": nodeInfo.Name,
|
||||||
"status": nodeInfo.Status,
|
"serverIp": nodeInfo.ServerIP,
|
||||||
"maxBandwidth": share.MaxBandwidth,
|
"status": nodeInfo.Status,
|
||||||
"currentFlow": share.CurrentFlow,
|
"maxBandwidth": share.MaxBandwidth,
|
||||||
"expiryTime": share.ExpiryTime,
|
"currentFlow": share.CurrentFlow,
|
||||||
"portRangeStart": share.PortRangeStart,
|
"expiryTime": share.ExpiryTime,
|
||||||
"portRangeEnd": share.PortRangeEnd,
|
"portRangeStart": share.PortRangeStart,
|
||||||
|
"portRangeEnd": share.PortRangeEnd,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,9 +1101,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
if existing != nil && existing.Status == 1 {
|
if existing != nil && existing.Status == 1 {
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
"reservationId": existing.ReservationID,
|
"reservationId": existing.ReservationID,
|
||||||
"allocatedPort": existing.Port,
|
"allocatedPort": existing.Port,
|
||||||
"bindingId": existing.BindingID,
|
"relayAuthVersion": relaysecurity.Version,
|
||||||
|
"bindingId": existing.BindingID,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1103,6 +1126,8 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
|||||||
existing.ServiceName = ""
|
existing.ServiceName = ""
|
||||||
existing.Strategy = "round"
|
existing.Strategy = "round"
|
||||||
existing.Target = ""
|
existing.Target = ""
|
||||||
|
existing.RelayAuth = ""
|
||||||
|
existing.RelayAuthVersion = 0
|
||||||
existing.Applied = 0
|
existing.Applied = 0
|
||||||
existing.Status = 1
|
existing.Status = 1
|
||||||
existing.UpdatedTime = now
|
existing.UpdatedTime = now
|
||||||
@@ -1111,9 +1136,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
"reservationId": existing.ReservationID,
|
"reservationId": existing.ReservationID,
|
||||||
"allocatedPort": existing.Port,
|
"allocatedPort": existing.Port,
|
||||||
"bindingId": existing.BindingID,
|
"relayAuthVersion": relaysecurity.Version,
|
||||||
|
"bindingId": existing.BindingID,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1142,9 +1168,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
"reservationId": runtime.ReservationID,
|
"reservationId": runtime.ReservationID,
|
||||||
"allocatedPort": runtime.Port,
|
"allocatedPort": runtime.Port,
|
||||||
"bindingId": runtime.BindingID,
|
"relayAuthVersion": relaysecurity.Version,
|
||||||
|
"bindingId": runtime.BindingID,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,6 +1238,20 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
|||||||
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
|
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, err := decodeRuntimeRelayAuth(runtime); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateFederationRelayAuth(runtime.RelayAuth, req.RelayAuth, req.Targets); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RelayAuth != nil {
|
||||||
|
encoded, _ := json.Marshal(req.RelayAuth)
|
||||||
|
runtime.RelayAuth = string(encoded)
|
||||||
|
runtime.RelayAuthVersion = relaysecurity.Version
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(runtime.BindingID) == "" {
|
if strings.TrimSpace(runtime.BindingID) == "" {
|
||||||
runtime.BindingID = randomToken(24)
|
runtime.BindingID = randomToken(24)
|
||||||
}
|
}
|
||||||
@@ -1230,9 +1271,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
"bindingId": runtime.BindingID,
|
"relayAuthVersion": relayAuthVersion(req.RelayAuth),
|
||||||
"reservationId": runtime.ReservationID,
|
"bindingId": runtime.BindingID,
|
||||||
"allocatedPort": runtime.Port,
|
"reservationId": runtime.ReservationID,
|
||||||
|
"allocatedPort": runtime.Port,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -495,19 +496,22 @@ func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
||||||
a := newCleanupAgent(t)
|
var failCompletion atomic.Bool
|
||||||
|
a := newCleanupAgent(t, func(r *repo.Repository) {
|
||||||
|
if err := r.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
|
||||||
|
if failCompletion.Load() && tx.Statement.Table == "peer_share_runtime" {
|
||||||
|
tx.AddError(errors.New("simulated completion failure"))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
|
||||||
share, err := a.h.repo.GetPeerShare(shareID)
|
share, err := a.h.repo.GetPeerShare(shareID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := a.h.repo.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
|
failCompletion.Store(true)
|
||||||
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"}})
|
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 {
|
if got.Code == 0 {
|
||||||
t.Fatal("completion database failure reported success")
|
t.Fatal("completion database failure reported success")
|
||||||
@@ -525,9 +529,7 @@ func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
|
|||||||
if !proof || !pending {
|
if !proof || !pending {
|
||||||
t.Fatalf("failure lost ownership or retry marker: %+v", items)
|
t.Fatalf("failure lost ownership or retry marker: %+v", items)
|
||||||
}
|
}
|
||||||
if err := a.h.repo.DB().Callback().Update().Remove("fail-runtime-release"); err != nil {
|
failCompletion.Store(false)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
|
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
|
||||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
|
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "", nil)
|
||||||
handler := service["handler"].(map[string]interface{})
|
handler := service["handler"].(map[string]interface{})
|
||||||
if handler["chain"] != "chain-next" {
|
if handler["chain"] != "chain-next" {
|
||||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||||
@@ -239,7 +239,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
|
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
|
||||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
|
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "", nil)
|
||||||
handler := service["handler"].(map[string]interface{})
|
handler := service["handler"].(map[string]interface{})
|
||||||
if handler["chain"] != "chain-next" {
|
if handler["chain"] != "chain-next" {
|
||||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||||
@@ -250,7 +250,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *te
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
|
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
|
||||||
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
|
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0", nil)
|
||||||
handler := service["handler"].(map[string]interface{})
|
handler := service["handler"].(map[string]interface{})
|
||||||
if _, hasChain := handler["chain"]; hasChain {
|
if _, hasChain := handler["chain"]; hasChain {
|
||||||
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
|
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
|
||||||
@@ -265,7 +265,7 @@ func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
|
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
|
||||||
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
|
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "", nil)
|
||||||
handler := service["handler"].(map[string]interface{})
|
handler := service["handler"].(map[string]interface{})
|
||||||
meta := handler["metadata"].(map[string]interface{})
|
meta := handler["metadata"].(map[string]interface{})
|
||||||
if meta["nodelay"] != true {
|
if meta["nodelay"] != true {
|
||||||
@@ -274,10 +274,14 @@ func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
||||||
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
|
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "", nil)
|
||||||
handler := service["handler"].(map[string]interface{})
|
handler := service["handler"].(map[string]interface{})
|
||||||
if _, hasMeta := handler["metadata"]; hasMeta {
|
metadata, _ := handler["metadata"].(map[string]interface{})
|
||||||
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
|
if _, hasNodelay := metadata["nodelay"]; hasNodelay {
|
||||||
|
t.Fatal("non-TLS protocol must not set nodelay")
|
||||||
|
}
|
||||||
|
if metadata["chain.strict"] != true {
|
||||||
|
t.Fatal("middle relay must require its configured chain")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -305,11 +305,17 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
|||||||
|
|
||||||
h := New(r, "test-jwt-secret")
|
h := New(r, "test-jwt-secret")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
// Model an unavailable provider entirely on loopback instead of relying on
|
||||||
|
// external DNS or network restrictions to produce the sync error.
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
INSERT INTO node(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, is_remote, remote_url, remote_token, remote_config)
|
INSERT INTO node(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, is_remote, remote_url, remote_token, remote_config)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
||||||
t.Fatalf("insert remote node: %v", err)
|
t.Fatalf("insert remote node: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +354,7 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
|||||||
|
|
||||||
syncErr, _ := first["syncError"].(string)
|
syncErr, _ := first["syncError"].(string)
|
||||||
if syncErr == "" {
|
if syncErr == "" {
|
||||||
t.Fatalf("expected non-empty syncError field on unreachable provider")
|
t.Fatalf("expected non-empty syncError field on unavailable provider")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,11 +864,15 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
|||||||
|
|
||||||
h := New(r, "test-jwt-secret")
|
h := New(r, "test-jwt-secret")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
INSERT INTO node(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, is_remote, remote_url, remote_token, remote_config)
|
INSERT INTO node(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, is_remote, remote_url, remote_token, remote_config)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
||||||
t.Fatalf("insert remote node: %v", err)
|
t.Fatalf("insert remote node: %v", err)
|
||||||
}
|
}
|
||||||
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
|
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
|
||||||
@@ -882,8 +892,8 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
|||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
tunnelAID, nodeID, 2, 1, provider.URL, "rk-a", "rb-a", 31001, 1, now, now,
|
||||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
tunnelBID, nodeID, 3, 0, provider.URL, "rk-b", "rb-b", 31002, 1, now, now,
|
||||||
).Error; err != nil {
|
).Error; err != nil {
|
||||||
t.Fatalf("insert federation bindings: %v", err)
|
t.Fatalf("insert federation bindings: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type cleanupAgent struct {
|
|||||||
commands []cleanupAgentCommand
|
commands []cleanupAgentCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCleanupAgent(t *testing.T) *cleanupAgent {
|
func newCleanupAgent(t *testing.T, setup ...func(*repo.Repository)) *cleanupAgent {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
|
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,6 +48,11 @@ func newCleanupAgent(t *testing.T) *cleanupAgent {
|
|||||||
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 {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Register test-local database callbacks before WebSocket goroutines can
|
||||||
|
// execute them. Keep registries immutable until the fixture is closed.
|
||||||
|
for _, configure := range setup {
|
||||||
|
configure(r)
|
||||||
|
}
|
||||||
server := ws.NewServer(r, "test-jwt-secret")
|
server := ws.NewServer(r, "test-jwt-secret")
|
||||||
online := make(chan struct{})
|
online := make(chan struct{})
|
||||||
server.SetNodeOnlineHook(func(int64) { close(online) })
|
server.SetNodeOnlineHook(func(int64) { close(online) })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -393,6 +394,48 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
|||||||
}, nil
|
}, 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) {
|
func (h *Handler) pauseUserForwards(userID int64, now int64) {
|
||||||
forwards, err := h.listActiveForwardsByUser(userID)
|
forwards, err := h.listActiveForwardsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ type Handler struct {
|
|||||||
healthCheck *health.Checker
|
healthCheck *health.Checker
|
||||||
nftablesManager nftablesRuntimeManager
|
nftablesManager nftablesRuntimeManager
|
||||||
|
|
||||||
|
tunnelLifecycleMu sync.Mutex
|
||||||
|
tunnelLifecycleLocks map[int64]*tunnelLifecycleLock
|
||||||
|
|
||||||
peerResourceMu sync.Mutex
|
peerResourceMu sync.Mutex
|
||||||
captchaMu sync.Mutex
|
captchaMu sync.Mutex
|
||||||
captchaTokens map[string]int64
|
captchaTokens map[string]int64
|
||||||
@@ -183,6 +186,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
|
||||||
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
|
||||||
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
|
||||||
|
mux.HandleFunc("/api/v1/system/upgrade/status", h.systemUpgradeStatus)
|
||||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||||
@@ -224,6 +228,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||||
|
mux.HandleFunc("/api/v1/tunnel/batch-redeploy/stream", h.tunnelBatchRedeployStream)
|
||||||
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
|
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
|
||||||
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
|
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
|
||||||
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
|
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
|
||||||
@@ -244,6 +249,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
|
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
||||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
||||||
|
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
|
||||||
|
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
|
|||||||
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
|
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
|
||||||
log.Printf("federation cleanup remains pending: %v", err)
|
log.Printf("federation cleanup remains pending: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := h.retryPendingTunnelRuntimeCleanup(); err != nil {
|
||||||
|
log.Printf("tunnel runtime cleanup remains pending: %v", err)
|
||||||
|
}
|
||||||
h.retryPendingPeerShareOperations()
|
h.retryPendingPeerShareOperations()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
"go-backend/internal/security"
|
"go-backend/internal/security"
|
||||||
"go-backend/internal/store/model"
|
"go-backend/internal/store/model"
|
||||||
"go-backend/internal/store/repo"
|
"go-backend/internal/store/repo"
|
||||||
@@ -205,6 +206,15 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status != 1 {
|
||||||
|
// Revoke access before optional quota/group updates can fail or return.
|
||||||
|
if err := h.disableUserAccess(id, now); err != nil {
|
||||||
|
result := response.Err(-2, fmt.Sprintf("账号已禁用,以下权限或节点暂停未确认;其他转发已继续处理,请排查后重试禁用:\n%v", err))
|
||||||
|
result.Data = map[string]interface{}{"userDisabled": true}
|
||||||
|
response.WriteJSON(w, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
|
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
|
||||||
if hasDailyQuota || hasMonthlyQuota {
|
if hasDailyQuota || hasMonthlyQuota {
|
||||||
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
|
||||||
@@ -243,6 +253,13 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if status != 1 {
|
||||||
|
// Group changes may have created additional tunnel grants.
|
||||||
|
if err := h.repo.DisableUserTunnels(id); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if oldUser.MaxConn != maxConn {
|
if oldUser.MaxConn != maxConn {
|
||||||
warnings, syncErr := h.syncUserMaxConnForwards(id)
|
warnings, syncErr := h.syncUserMaxConnForwards(id)
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
@@ -832,10 +849,70 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tunnelInIP sql.NullString
|
||||||
|
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
|
||||||
|
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
|
||||||
|
}
|
||||||
|
tunnelProtocol := "tls"
|
||||||
|
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||||
|
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||||
|
}
|
||||||
|
tunnel := model.Tunnel{
|
||||||
|
RelayAuthMode: relaysecurity.Legacy,
|
||||||
|
Name: name,
|
||||||
|
TrafficRatio: trafficRatio,
|
||||||
|
Type: typeVal,
|
||||||
|
Protocol: tunnelProtocol,
|
||||||
|
Flow: flow,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
Status: status,
|
||||||
|
InIP: tunnelInIP,
|
||||||
|
Inx: inx,
|
||||||
|
IPPreference: ipPreference,
|
||||||
|
ProbeTargetHost: probeTargetHost,
|
||||||
|
ProbeTargetPort: probeTargetPort,
|
||||||
|
}
|
||||||
|
if typeVal == 2 {
|
||||||
|
tunnel.RelayAuthMode = relaysecurity.Required
|
||||||
|
}
|
||||||
|
if err := tx.Create(&tunnel).Error; err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tunnelID := tunnel.ID
|
||||||
|
unlock, locked := h.tryLockNewTunnelLifecycle(tunnelID)
|
||||||
|
if !locked {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("隧道正在变更,请重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
runtimeState.TunnelID = tunnelID
|
||||||
|
runtimeState.RelayAuthMode = tunnel.RelayAuthMode
|
||||||
|
if err := h.resolveTunnelRelayAuth(tx, runtimeState, true); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typeVal == 2 {
|
||||||
|
if err := h.persistPreparedTunnelChainsTx(tx, runtimeState); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The lifecycle lock is held before publishing this ID. Network calls below
|
||||||
|
// must not monopolize SQLite's only connection or a database transaction.
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failCreate := func(err error, code int) {
|
||||||
|
err = errors.Join(err, h.deleteTunnelByID(tunnelID))
|
||||||
|
response.WriteJSON(w, response.Err(code, err.Error()))
|
||||||
|
}
|
||||||
if len(runtimeState.InNodes) > 0 {
|
if len(runtimeState.InNodes) > 0 {
|
||||||
firstNodeID := runtimeState.InNodes[0].NodeID
|
firstNodeID := runtimeState.InNodes[0].NodeID
|
||||||
isRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)
|
entryNode := runtimeState.Nodes[firstNodeID]
|
||||||
if isRemote == 1 {
|
if entryNode != nil && entryNode.IsRemote == 1 {
|
||||||
fc := client.NewFederationClient()
|
fc := client.NewFederationClient()
|
||||||
|
|
||||||
targetProto := "tcp"
|
targetProto := "tcp"
|
||||||
@@ -862,17 +939,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if targetPort > 0 && targetAddr != "" {
|
if targetPort > 0 && targetAddr != "" {
|
||||||
inNodeRec := runtimeState.Nodes[firstNodeID]
|
inNodeRec := runtimeState.Nodes[firstNodeID]
|
||||||
if err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {
|
if err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
failCreate(err, -1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
_, err := fc.CreateTunnel(entryNode.RemoteURL, entryNode.RemoteToken, localDomain, targetProto, targetPort, targetAddr)
|
||||||
localDomain := ""
|
|
||||||
if domainCfg != nil {
|
|
||||||
localDomain = domainCfg.Value
|
|
||||||
}
|
|
||||||
_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("Remote tunnel creation failed: "+err.Error()))
|
failCreate(fmt.Errorf("remote tunnel creation failed: %w", err), -1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -880,66 +952,53 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tunnelInIP sql.NullString
|
|
||||||
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
|
|
||||||
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
|
|
||||||
}
|
|
||||||
tunnelProtocol := "tls"
|
|
||||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
|
||||||
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
|
||||||
}
|
|
||||||
tunnel := model.Tunnel{
|
|
||||||
Name: name,
|
|
||||||
TrafficRatio: trafficRatio,
|
|
||||||
Type: typeVal,
|
|
||||||
Protocol: tunnelProtocol,
|
|
||||||
Flow: flow,
|
|
||||||
CreatedTime: now,
|
|
||||||
UpdatedTime: now,
|
|
||||||
Status: status,
|
|
||||||
InIP: tunnelInIP,
|
|
||||||
Inx: inx,
|
|
||||||
IPPreference: ipPreference,
|
|
||||||
ProbeTargetHost: probeTargetHost,
|
|
||||||
ProbeTargetPort: probeTargetPort,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&tunnel).Error; err != nil {
|
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tunnelID := tunnel.ID
|
|
||||||
runtimeState.TunnelID = tunnelID
|
|
||||||
var federationBindings []repo.FederationTunnelBinding
|
var federationBindings []repo.FederationTunnelBinding
|
||||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
|
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
failCreate(err, -1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tx = h.repo.BeginTx()
|
||||||
|
if tx.Error != nil {
|
||||||
|
failCreate(errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if typeVal == 2 {
|
||||||
|
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, tunnelID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
failCreate(errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs)), -2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
applyTunnelPortsToRequest(req, runtimeState)
|
applyTunnelPortsToRequest(req, runtimeState)
|
||||||
if err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
if err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
failCreate(err, -2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
|
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
failCreate(err, -2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
failCreate(err, -2)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if typeVal == 2 {
|
if typeVal == 2 {
|
||||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
|
if isTunnelRuntimePending(applyErr) {
|
||||||
|
response.WriteJSON(w, tunnelRuntimePendingResponse(tunnelID, applyErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
|
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
|
||||||
if cleanupErr := h.cleanupFederationRuntime(tunnelID); cleanupErr != nil {
|
if cleanupErr := h.cleanupFederationRuntime(tunnelID); cleanupErr != nil {
|
||||||
applyErr = errors.Join(applyErr, cleanupErr)
|
applyErr = errors.Join(applyErr, cleanupErr)
|
||||||
@@ -953,31 +1012,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
|
|
||||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
|
||||||
if err != nil || tunnel.Type != 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chainRows, err := h.listChainNodesForTunnel(tunnelID)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
|
||||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
|
||||||
|
|
||||||
for _, row := range chainRows {
|
|
||||||
if row.ChainType == 1 {
|
|
||||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
|
||||||
} else if row.ChainType == 2 {
|
|
||||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
|
||||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
|
||||||
} else if row.ChainType == 3 {
|
|
||||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tunnelRuntimeServiceNames(tunnelID int64) []string {
|
func tunnelRuntimeServiceNames(tunnelID int64) []string {
|
||||||
return []string{
|
return []string{
|
||||||
fmt.Sprintf("tunnel_%d", tunnelID),
|
fmt.Sprintf("tunnel_%d", tunnelID),
|
||||||
@@ -1028,20 +1062,6 @@ func removedTunnelRuntimeNodeIDs(oldRows, newRows []chainNodeRecord, needsRuntim
|
|||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) cleanupObsoleteTunnelRuntime(tunnelID int64, oldRows, newRows []chainNodeRecord) {
|
|
||||||
if h == nil || tunnelID <= 0 || len(oldRows) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
|
||||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
|
|
||||||
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
|
||||||
}
|
|
||||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
|
||||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
|
|
||||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
@@ -1085,6 +1105,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
unlock := h.lockTunnelLifecycle(id)
|
||||||
|
defer unlock()
|
||||||
typeVal := asInt(req["type"], 1)
|
typeVal := asInt(req["type"], 1)
|
||||||
ipPreference := asString(req["ipPreference"])
|
ipPreference := asString(req["ipPreference"])
|
||||||
_, hasProbeTargetHost := req["probeTargetHost"]
|
_, hasProbeTargetHost := req["probeTargetHost"]
|
||||||
@@ -1140,6 +1162,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeState.TunnelID = id
|
runtimeState.TunnelID = id
|
||||||
|
runtimeState.RelayAuthMode = oldTunnel.RelayAuthMode
|
||||||
|
if typeVal == 2 && oldTunnel.Type != 2 {
|
||||||
|
runtimeState.RelayAuthMode = relaysecurity.Required
|
||||||
|
}
|
||||||
runtimeState.IPPreference = ipPreference
|
runtimeState.IPPreference = ipPreference
|
||||||
entryNodeIDs := make([]int64, 0, len(runtimeState.InNodes))
|
entryNodeIDs := make([]int64, 0, len(runtimeState.InNodes))
|
||||||
for _, inNode := range runtimeState.InNodes {
|
for _, inNode := range runtimeState.InNodes {
|
||||||
@@ -1183,12 +1209,18 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
if err := h.resolveTunnelRelayAuth(nil, runtimeState, true); err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if oldTunnel.Type == 2 && typeVal != 2 {
|
if err := h.preflightFederationRelayAuth(runtimeState, localDomain); err != nil {
|
||||||
h.cleanupTunnelRuntime(id)
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var federationBindings []repo.FederationTunnelBinding
|
var federationBindings []repo.FederationTunnelBinding
|
||||||
@@ -1263,6 +1295,19 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.repo.SetTunnelRelayAuthModeTx(tx, id, runtimeState.RelayAuthMode); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldTunnel.Type == 2 {
|
||||||
|
if err := h.queueObsoleteTunnelRuntimeTx(tx, id, oldChainRows, tunnelStateRuntimeRows(runtimeState)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
||||||
@@ -1276,13 +1321,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
|
h.syncTunnelForwardsEntryPorts(id, newEntryNodeIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var runtimePending error
|
||||||
if typeVal == 2 {
|
if typeVal == 2 {
|
||||||
applyRuntime := h.applyTunnelRuntime
|
applyRuntime := h.applyTunnelRuntime
|
||||||
if oldTunnel != nil && oldTunnel.Type == 2 {
|
if oldTunnel != nil && oldTunnel.Type == 2 {
|
||||||
applyRuntime = h.applyTunnelRuntimeUpsert
|
applyRuntime = h.applyTunnelRuntimeUpsert
|
||||||
}
|
}
|
||||||
createdChains, createdServices, applyErr := applyRuntime(runtimeState)
|
createdChains, createdServices, applyErr := applyRuntime(runtimeState)
|
||||||
if applyErr != nil {
|
if isTunnelRuntimePending(applyErr) {
|
||||||
|
runtimePending = applyErr
|
||||||
|
}
|
||||||
|
if applyErr != nil && runtimePending == nil {
|
||||||
updateProtocol := "tls"
|
updateProtocol := "tls"
|
||||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||||
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||||
@@ -1291,16 +1340,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
|
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
|
||||||
}
|
}
|
||||||
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(id))
|
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(id))
|
||||||
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
|
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newChainRows, _ := h.listChainNodesForTunnel(id)
|
runtimePending = errors.Join(runtimePending, h.acknowledgeTunnelServiceReplacements(id, createdServices))
|
||||||
h.cleanupObsoleteTunnelRuntime(id, oldChainRows, newChainRows)
|
|
||||||
}
|
}
|
||||||
|
runtimePending = errors.Join(runtimePending, h.retryTunnelRuntimeCleanupLocked(id))
|
||||||
|
|
||||||
oldType := 0
|
oldType := 0
|
||||||
if oldTunnel != nil {
|
if oldTunnel != nil {
|
||||||
@@ -1309,14 +1354,22 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if tunnelForwardRuntimeNeedsSync(oldType, typeVal, oldEntryNodeIDs, newEntryNodeIDs) {
|
if tunnelForwardRuntimeNeedsSync(oldType, typeVal, oldEntryNodeIDs, newEntryNodeIDs) {
|
||||||
forwards, fwdErr := h.listForwardsByTunnel(id)
|
forwards, fwdErr := h.listForwardsByTunnel(id)
|
||||||
if fwdErr != nil {
|
if fwdErr != nil {
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, tunnelRuntimePendingResponse(id, errors.Join(runtimePending, fwdErr)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var forwardErrors []error
|
||||||
for i := range forwards {
|
for i := range forwards {
|
||||||
_ = h.syncForwardServices(&forwards[i], "UpdateService", true)
|
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
|
||||||
|
forwardErrors = append(forwardErrors, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
runtimePending = errors.Join(runtimePending, errors.Join(forwardErrors...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if runtimePending != nil {
|
||||||
|
response.WriteJSON(w, tunnelRuntimePendingResponse(id, runtimePending))
|
||||||
|
return
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,6 +1531,15 @@ func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecor
|
|||||||
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
|
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(tx, node.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, reserved := range pendingPorts {
|
||||||
|
if reserved == port {
|
||||||
|
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
|
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1675,11 +1737,12 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
unlock := h.lockTunnelLifecycle(id)
|
||||||
|
defer unlock()
|
||||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.cleanupTunnelRuntime(id)
|
|
||||||
if err := h.deleteTunnelByID(id); err != nil {
|
if err := h.deleteTunnelByID(id); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1740,24 +1803,27 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail := 0
|
fail := 0
|
||||||
failures := make([]batchFailureDetail, 0)
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
func() {
|
||||||
if _, err := h.getTunnelRecord(id); err != nil {
|
unlock := h.lockTunnelLifecycle(id)
|
||||||
fail++
|
defer unlock()
|
||||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||||
continue
|
if _, err := h.getTunnelRecord(id); err != nil {
|
||||||
}
|
fail++
|
||||||
if err := h.cleanupFederationRuntime(id); err != nil {
|
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||||
fail++
|
return
|
||||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
}
|
||||||
continue
|
if err := h.cleanupFederationRuntime(id); err != nil {
|
||||||
}
|
fail++
|
||||||
h.cleanupTunnelRuntime(id)
|
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||||
if err := h.deleteTunnelByID(id); err != nil {
|
return
|
||||||
fail++
|
}
|
||||||
failures = appendBatchFailure(failures, id, tunnelName, err)
|
if err := h.deleteTunnelByID(id); err != nil {
|
||||||
} else {
|
fail++
|
||||||
success++
|
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||||
}
|
} else {
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||||
}
|
}
|
||||||
@@ -1776,14 +1842,15 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
|||||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||||
|
|
||||||
state := &tunnelCreateState{
|
state := &tunnelCreateState{
|
||||||
TunnelID: tunnelID,
|
RelayAuthMode: tunnel.RelayAuthMode,
|
||||||
Type: tunnel.Type,
|
TunnelID: tunnelID,
|
||||||
IPPreference: ipPreference,
|
Type: tunnel.Type,
|
||||||
InNodes: make([]tunnelRuntimeNode, 0),
|
IPPreference: ipPreference,
|
||||||
ChainHops: make([][]tunnelRuntimeNode, 0),
|
InNodes: make([]tunnelRuntimeNode, 0),
|
||||||
OutNodes: make([]tunnelRuntimeNode, 0),
|
ChainHops: make([][]tunnelRuntimeNode, 0),
|
||||||
Nodes: make(map[int64]*nodeRecord),
|
OutNodes: make([]tunnelRuntimeNode, 0),
|
||||||
NodeIDList: make([]int64, 0),
|
Nodes: make(map[int64]*nodeRecord),
|
||||||
|
NodeIDList: make([]int64, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||||
@@ -1840,10 +1907,23 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
|||||||
state.Nodes[id] = node
|
state.Nodes[id] = node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.resolveTunnelRelayAuth(nil, state, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
||||||
|
unlock := h.lockTunnelLifecycle(tunnelID)
|
||||||
|
defer unlock()
|
||||||
|
return h.redeployTunnelAndForwardsLocked(tunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) redeployTunnelAndForwardsLocked(tunnelID int64) error {
|
||||||
|
if err := h.retryTunnelRuntimeCleanupLocked(tunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
tunnel, err := h.getTunnelRecord(tunnelID)
|
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1857,28 +1937,44 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
|||||||
if err := validateTunnelRuntimeBeforeApply(state); err != nil {
|
if err := validateTunnelRuntimeBeforeApply(state); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
|
existingBindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.cleanupTunnelRuntime(tunnelID)
|
// Reserve/apply use stable resource keys and upsert the peer runtime.
|
||||||
|
// A failed refresh must never release bindings that were already live.
|
||||||
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
|
||||||
|
newRefs := make([]federationRuntimeReleaseRef, 0, len(federationReleaseRefs))
|
||||||
|
for _, ref := range federationReleaseRefs {
|
||||||
|
existing := false
|
||||||
|
for _, binding := range existingBindings {
|
||||||
|
if strings.TrimSpace(binding.RemoteURL) == strings.TrimSpace(ref.RemoteURL) && binding.ResourceKey == ref.ResourceKey {
|
||||||
|
existing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !existing {
|
||||||
|
newRefs = append(newRefs, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
if fedErr != nil {
|
if fedErr != nil {
|
||||||
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||||
}
|
}
|
||||||
tx := h.repo.BeginTx()
|
tx := h.repo.BeginTx()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(newRefs))
|
||||||
}
|
}
|
||||||
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||||
}
|
}
|
||||||
if commitErr := tx.Commit().Error; commitErr != nil {
|
if commitErr := tx.Commit().Error; commitErr != nil {
|
||||||
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
|
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(newRefs))
|
||||||
}
|
}
|
||||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
_, services, applyErr := h.applyTunnelRuntimeUpsert(state)
|
||||||
|
applyErr = errors.Join(applyErr, h.acknowledgeTunnelServiceReplacements(tunnelID, services))
|
||||||
|
applyErr = errors.Join(applyErr, h.retryTunnelRuntimeCleanupLocked(tunnelID))
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(tunnelID))
|
|
||||||
return applyErr
|
return applyErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1950,51 +2046,37 @@ func normalizeBatchFailureReason(reason string) string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) redeployTunnelConfiguration(tunnelID int64) error {
|
||||||
|
if _, err := h.getTunnelRecord(tunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if nftMode, entryNodeIDs, err := h.tunnelUsesNftables(tunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if nftMode {
|
||||||
|
if len(entryNodeIDs) == 0 {
|
||||||
|
return errors.New("nftables 转发缺少入口节点")
|
||||||
|
}
|
||||||
|
return h.reconcileNftablesNodeByRequest(entryNodeIDs[0])
|
||||||
|
}
|
||||||
|
return h.redeployTunnelAndForwards(tunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
ids := idsFromBody(r, w)
|
ids := idsFromBody(r, w)
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
success := 0
|
result := batchOperationResult{}
|
||||||
fail := 0
|
|
||||||
failures := make([]batchFailureDetail, 0)
|
|
||||||
for _, tunnelID := range ids {
|
for _, tunnelID := range ids {
|
||||||
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
|
name, _ := h.repo.GetTunnelName(tunnelID)
|
||||||
tunnelName := ""
|
if err := h.redeployTunnelConfiguration(tunnelID); err != nil {
|
||||||
if tunnelErr == nil && tunnel != nil {
|
result.FailCount++
|
||||||
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
|
result.Failures = appendBatchFailure(result.Failures, tunnelID, name, err)
|
||||||
|
} else {
|
||||||
|
result.SuccessCount++
|
||||||
}
|
}
|
||||||
if tunnelErr != nil {
|
|
||||||
fail++
|
|
||||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if nftMode, entryNodeIDs, modeErr := h.tunnelUsesNftables(tunnelID); modeErr != nil {
|
|
||||||
fail++
|
|
||||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, modeErr)
|
|
||||||
continue
|
|
||||||
} else if nftMode {
|
|
||||||
if len(entryNodeIDs) == 0 {
|
|
||||||
fail++
|
|
||||||
failures = appendBatchFailureReason(failures, tunnelID, tunnelName, "nftables 转发缺少入口节点")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if reconcileErr := h.reconcileNftablesNodeByRequest(entryNodeIDs[0]); reconcileErr != nil {
|
|
||||||
fail++
|
|
||||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, reconcileErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
success++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
|
||||||
fail++
|
|
||||||
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
success++
|
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
response.WriteJSON(w, response.OK(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -3333,6 +3415,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tunnelRuntimeNode struct {
|
type tunnelRuntimeNode struct {
|
||||||
|
RelayAuth *relaysecurity.Credentials
|
||||||
NodeID int64
|
NodeID int64
|
||||||
Protocol string
|
Protocol string
|
||||||
Strategy string
|
Strategy string
|
||||||
@@ -3343,14 +3426,15 @@ type tunnelRuntimeNode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tunnelCreateState struct {
|
type tunnelCreateState struct {
|
||||||
TunnelID int64
|
RelayAuthMode string
|
||||||
Type int
|
TunnelID int64
|
||||||
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
|
Type int
|
||||||
InNodes []tunnelRuntimeNode
|
IPPreference string // "" = auto, "v4" = prefer IPv4, "v6" = prefer IPv6
|
||||||
ChainHops [][]tunnelRuntimeNode
|
InNodes []tunnelRuntimeNode
|
||||||
OutNodes []tunnelRuntimeNode
|
ChainHops [][]tunnelRuntimeNode
|
||||||
Nodes map[int64]*nodeRecord
|
OutNodes []tunnelRuntimeNode
|
||||||
NodeIDList []int64
|
Nodes map[int64]*nodeRecord
|
||||||
|
NodeIDList []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||||
@@ -3524,6 +3608,21 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, row := range tunnelStateRuntimeRows(state) {
|
||||||
|
if row.Port <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ports, err := h.repo.PendingTunnelCleanupPortsTx(tx, row.NodeID, excludeTunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, port := range ports {
|
||||||
|
if port == row.Port {
|
||||||
|
return nil, errors.New("节点端口仍在等待旧运行时清理")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3717,6 +3816,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
if h == nil || state == nil {
|
if h == nil || state == nil {
|
||||||
return bindings, releaseRefs, nil
|
return bindings, releaseRefs, nil
|
||||||
}
|
}
|
||||||
|
if err := h.preflightFederationRelayAuth(state, localDomain); err != nil {
|
||||||
|
return bindings, releaseRefs, err
|
||||||
|
}
|
||||||
|
|
||||||
fc := client.NewFederationClient()
|
fc := client.NewFederationClient()
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
@@ -3752,6 +3855,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if outNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
|
||||||
|
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
|
||||||
|
}
|
||||||
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
|
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
|
||||||
outNode = state.OutNodes[outIdx]
|
outNode = state.OutNodes[outIdx]
|
||||||
|
|
||||||
@@ -3759,6 +3865,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
ReservationID: reserveRes.ReservationID,
|
ReservationID: reserveRes.ReservationID,
|
||||||
ResourceKey: resourceKey,
|
ResourceKey: resourceKey,
|
||||||
Role: "exit",
|
Role: "exit",
|
||||||
|
RelayAuth: outNode.RelayAuth,
|
||||||
Protocol: defaultString(outNode.Protocol, "tls"),
|
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||||
Strategy: defaultString(outNode.Strategy, "round"),
|
Strategy: defaultString(outNode.Strategy, "round"),
|
||||||
}
|
}
|
||||||
@@ -3820,6 +3927,9 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if chainNode.RelayAuth != nil && reserveRes.RelayAuthVersion != relaysecurity.Version {
|
||||||
|
return bindings, releaseRefs, errors.New("remote reservation does not support required relay authentication")
|
||||||
|
}
|
||||||
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
|
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
|
||||||
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||||
|
|
||||||
@@ -3843,9 +3953,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
return bindings, releaseRefs, errors.New("节点端口不能为空")
|
return bindings, releaseRefs, errors.New("节点端口不能为空")
|
||||||
}
|
}
|
||||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: target.Port,
|
RelayAuth: target.RelayAuth,
|
||||||
Protocol: defaultString(target.Protocol, "tls"),
|
Port: target.Port,
|
||||||
|
Protocol: defaultString(target.Protocol, "tls"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3853,6 +3964,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
|||||||
ReservationID: reserveRes.ReservationID,
|
ReservationID: reserveRes.ReservationID,
|
||||||
ResourceKey: resourceKey,
|
ResourceKey: resourceKey,
|
||||||
Role: "middle",
|
Role: "middle",
|
||||||
|
RelayAuth: chainNode.RelayAuth,
|
||||||
Protocol: defaultString(chainNode.Protocol, "tls"),
|
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||||
Strategy: runtimeStrategyForTargets(chainNode, nextTargets),
|
Strategy: runtimeStrategyForTargets(chainNode, nextTargets),
|
||||||
Targets: applyTargets,
|
Targets: applyTargets,
|
||||||
@@ -4017,6 +4129,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
return createdChains, createdServices, nil
|
return createdChains, createdServices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pending []error
|
||||||
for _, inNode := range state.InNodes {
|
for _, inNode := range state.InNodes {
|
||||||
targets := state.OutNodes
|
targets := state.OutNodes
|
||||||
if len(state.ChainHops) > 0 {
|
if len(state.ChainHops) > 0 {
|
||||||
@@ -4030,6 +4143,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
}
|
}
|
||||||
if err := h.applyTunnelChainOnNode(inNode.NodeID, chainData, upsert); err != nil {
|
if err := h.applyTunnelChainOnNode(inNode.NodeID, chainData, upsert); err != nil {
|
||||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
pending = append(pending, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||||
@@ -4046,7 +4160,11 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
|
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
|
||||||
}
|
}
|
||||||
node := state.Nodes[chainNode.NodeID]
|
node := state.Nodes[chainNode.NodeID]
|
||||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
if node != nil && node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node != nil && node.Status != 1 {
|
||||||
|
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||||
@@ -4055,6 +4173,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
}
|
}
|
||||||
if err := h.applyTunnelChainOnNode(chainNode.NodeID, chainData, upsert); err != nil {
|
if err := h.applyTunnelChainOnNode(chainNode.NodeID, chainData, upsert); err != nil {
|
||||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
pending = append(pending, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
@@ -4064,6 +4183,7 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||||
if err := h.addTunnelServiceOnNodeWithMode(chainNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
if err := h.addTunnelServiceOnNodeWithMode(chainNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
pending = append(pending, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
@@ -4074,12 +4194,17 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
|
|
||||||
for _, outNode := range state.OutNodes {
|
for _, outNode := range state.OutNodes {
|
||||||
node := state.Nodes[outNode.NodeID]
|
node := state.Nodes[outNode.NodeID]
|
||||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
if node != nil && node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node != nil && node.Status != 1 {
|
||||||
|
pending = append(pending, fmt.Errorf("节点 %s 不在线", nodeDisplayName(node)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||||
if err := h.addTunnelServiceOnNodeWithMode(outNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
if err := h.addTunnelServiceOnNodeWithMode(outNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
pending = append(pending, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||||
@@ -4087,6 +4212,9 @@ func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bo
|
|||||||
createdServices = append(createdServices, outNode.NodeID)
|
createdServices = append(createdServices, outNode.NodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(pending) > 0 {
|
||||||
|
return createdChains, createdServices, &tunnelRuntimePendingError{cause: errors.Join(pending...)}
|
||||||
|
}
|
||||||
return createdChains, createdServices, nil
|
return createdChains, createdServices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4107,7 +4235,15 @@ func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes
|
|||||||
log.Printf("best_exit: invalid chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
log.Printf("best_exit: invalid chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||||
return fmt.Errorf("invalid best exit chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
return fmt.Errorf("invalid best exit chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||||
}
|
}
|
||||||
|
unlock := h.lockTunnelLifecycle(tunnelID)
|
||||||
|
defer unlock()
|
||||||
|
if err := h.validateBestExitSnapshot(tunnelID, ownerNodeID, outNodes, ipPreference); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
targets := chainRecordsToRuntimeTargets(outNodes)
|
targets := chainRecordsToRuntimeTargets(outNodes)
|
||||||
|
if err := h.resolveBestExitRelayAuth(tunnelID, targets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
orderedIDs := make([]int64, 0, len(scores))
|
orderedIDs := make([]int64, 0, len(scores))
|
||||||
for _, score := range scores {
|
for _, score := range scores {
|
||||||
if score.ExitNodeID > 0 {
|
if score.ExitNodeID > 0 {
|
||||||
@@ -4188,9 +4324,10 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
|
|||||||
return errors.New("节点端口不能为空")
|
return errors.New("节点端口不能为空")
|
||||||
}
|
}
|
||||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: target.Port,
|
RelayAuth: target.RelayAuth,
|
||||||
Protocol: defaultString(target.Protocol, "tls"),
|
Port: target.Port,
|
||||||
|
Protocol: defaultString(target.Protocol, "tls"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4212,8 +4349,30 @@ func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, own
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var credentials *relaysecurity.Credentials
|
||||||
|
tunnel, loadErr := h.repo.GetTunnelRecord(tunnelID)
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
if tunnel == nil {
|
||||||
|
return errors.New("tunnel not found")
|
||||||
|
}
|
||||||
|
if tunnel.RelayAuthMode == relaysecurity.Required {
|
||||||
|
credentials, err = h.repo.RelayEndpointCredentials(nil, tunnelID, ownerNodeID, 2, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, e := client.NewFederationClient().Connect(remoteURL, remoteToken, h.federationLocalDomain())
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if info.RelayAuthVersion != relaysecurity.Version {
|
||||||
|
return errors.New("remote does not support required relay authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err = client.NewFederationClient().ApplyRole(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeApplyRoleRequest{
|
_, err = client.NewFederationClient().ApplyRole(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeApplyRoleRequest{
|
||||||
ResourceKey: strings.TrimSpace(binding.ResourceKey),
|
ResourceKey: strings.TrimSpace(binding.ResourceKey),
|
||||||
|
RelayAuth: credentials,
|
||||||
Role: "middle",
|
Role: "middle",
|
||||||
Protocol: defaultString(ownerRuntimeNode.Protocol, "tls"),
|
Protocol: defaultString(ownerRuntimeNode.Protocol, "tls"),
|
||||||
Strategy: runtimeStrategyForTargets(ownerRuntimeNode, targets),
|
Strategy: runtimeStrategyForTargets(ownerRuntimeNode, targets),
|
||||||
@@ -4263,19 +4422,20 @@ func (h *Handler) addTunnelServiceOnNodeWithMode(nodeID, tunnelID int64, service
|
|||||||
if h == nil {
|
if h == nil {
|
||||||
return errors.New("invalid tunnel service context")
|
return errors.New("invalid tunnel service context")
|
||||||
}
|
}
|
||||||
|
if upsert {
|
||||||
|
// Update failure must not trigger deletion of an existing listener.
|
||||||
|
_, err := h.sendNodeCommand(nodeID, "UpdateService", serviceData, true, false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||||
if len(serviceData) > 0 {
|
if len(serviceData) > 0 {
|
||||||
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
|
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||||
serviceName = strings.TrimSpace(name)
|
serviceName = strings.TrimSpace(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
command := "AddService"
|
|
||||||
if upsert {
|
|
||||||
command = "UpdateService"
|
|
||||||
}
|
|
||||||
return retryTunnelServiceAddWithCleanup(
|
return retryTunnelServiceAddWithCleanup(
|
||||||
func() error {
|
func() error {
|
||||||
_, err := h.sendNodeCommand(nodeID, command, serviceData, true, false)
|
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
func() error {
|
func() error {
|
||||||
@@ -4366,6 +4526,12 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
|||||||
connector := map[string]interface{}{
|
connector := map[string]interface{}{
|
||||||
"type": "relay",
|
"type": "relay",
|
||||||
}
|
}
|
||||||
|
if target.RelayAuth != nil {
|
||||||
|
if err := target.RelayAuth.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connector["auth"] = target.RelayAuth.Config()
|
||||||
|
}
|
||||||
connectorMetadata := map[string]interface{}{}
|
connectorMetadata := map[string]interface{}{}
|
||||||
if isTCPTunnelProtocol(protocol) {
|
if isTCPTunnelProtocol(protocol) {
|
||||||
connectorMetadata["nodelay"] = true
|
connectorMetadata["nodelay"] = true
|
||||||
@@ -4438,6 +4604,9 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
|||||||
handlerCfg := map[string]interface{}{
|
handlerCfg := map[string]interface{}{
|
||||||
"type": "relay",
|
"type": "relay",
|
||||||
}
|
}
|
||||||
|
if chainNode.RelayAuth != nil {
|
||||||
|
handlerCfg["auth"] = chainNode.RelayAuth.Config()
|
||||||
|
}
|
||||||
handlerMetadata := map[string]interface{}{}
|
handlerMetadata := map[string]interface{}{}
|
||||||
if isTCPTunnelProtocol(protocol) {
|
if isTCPTunnelProtocol(protocol) {
|
||||||
handlerMetadata["nodelay"] = true
|
handlerMetadata["nodelay"] = true
|
||||||
@@ -4466,6 +4635,8 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
|||||||
"listener": buildTunnelListenerConfig(protocol),
|
"listener": buildTunnelListenerConfig(protocol),
|
||||||
}
|
}
|
||||||
if chainNode.ChainType == 2 {
|
if chainNode.ChainType == 2 {
|
||||||
|
handlerMetadata["chain.strict"] = true
|
||||||
|
handlerCfg["metadata"] = handlerMetadata
|
||||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
|
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
|
||||||
}
|
}
|
||||||
if chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != "" {
|
if chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||||
@@ -4765,6 +4936,9 @@ func (h *Handler) deleteTunnelByID(id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.retryTunnelRuntimeCleanupLocked(id); err != nil {
|
||||||
|
return &tunnelDeletionCleanupPendingError{cause: err}
|
||||||
|
}
|
||||||
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -5456,6 +5630,15 @@ func (h *Handler) validateForwardPortAvailability(node *nodeRecord, port int, cu
|
|||||||
if h == nil || h.repo == nil || node == nil || port <= 0 {
|
if h == nil || h.repo == nil || node == nil || port <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
pendingPorts, err := h.repo.PendingTunnelCleanupPortsTx(nil, node.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, reserved := range pendingPorts {
|
||||||
|
if reserved == port {
|
||||||
|
return fmt.Errorf("节点 %s 端口 %d 仍在等待旧运行时清理", node.Name, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
|
occupied, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, currentForwardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int6
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
|
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
|
||||||
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
|
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return runtimenft.NodePlan{}, nil, err
|
return runtimenft.NodePlan{}, nil, err
|
||||||
@@ -162,6 +163,9 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
|
|||||||
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
|
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
|
||||||
for i := range forwards {
|
for i := range forwards {
|
||||||
forward := &forwards[i]
|
forward := &forwards[i]
|
||||||
|
if slices.Contains(excludedForwardIDs, forward.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||||
if err != nil || tunnel == nil || tunnel.Status != 1 {
|
if err != nil || tunnel == nil || tunnel.Status != 1 {
|
||||||
continue
|
continue
|
||||||
@@ -201,11 +205,11 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *mod
|
|||||||
return plan, cfg, nil
|
return plan, cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) syncNftablesNode(nodeID int64) error {
|
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
|
||||||
if h == nil || h.repo == nil {
|
if h == nil || h.repo == nil {
|
||||||
return errors.New("handler not initialized")
|
return errors.New("handler not initialized")
|
||||||
}
|
}
|
||||||
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID)
|
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func passkeyConfig() (*webauthn.WebAuthn, string) {
|
|||||||
RPID: host,
|
RPID: host,
|
||||||
RPDisplayName: "FLVX",
|
RPDisplayName: "FLVX",
|
||||||
RPOrigins: []string{origin},
|
RPOrigins: []string{origin},
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementPreferred},
|
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
|
||||||
Timeouts: webauthn.TimeoutsConfig{
|
Timeouts: webauthn.TimeoutsConfig{
|
||||||
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
||||||
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
|
||||||
@@ -112,6 +112,17 @@ func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (we
|
|||||||
return c.session, true
|
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) {
|
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
|
||||||
user, err := h.repo.GetUserByID(userID)
|
user, err := h.repo.GetUserByID(userID)
|
||||||
if err != nil || user == nil || user.Status != 1 {
|
if err != nil || user == nil || user.Status != 1 {
|
||||||
@@ -178,7 +189,7 @@ func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
|
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
options, session, err := wa.BeginRegistration(u)
|
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||||
return
|
return
|
||||||
@@ -278,26 +289,16 @@ func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
if !passkeyBody(r, &req) {
|
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.repo.GetUserByUsername(strings.TrimSpace(req.Username))
|
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
if err != nil || user == nil || user.Status != 1 {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u, err := h.loadPasskeyUser(user.ID)
|
|
||||||
if err != nil || len(u.credentials) == 0 {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
options, session, err := wa.BeginLogin(u, webauthn.WithUserVerification(protocol.VerificationRequired))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: user.ID, kind: "login", origin: origin, session: *session})
|
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
|
||||||
if !ok {
|
if !ok {
|
||||||
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
|
||||||
return
|
return
|
||||||
@@ -320,22 +321,35 @@ func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Login sessions carry the user ID; the caller cannot choose a different account at finish.
|
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
|
||||||
h.passkeyMu.Lock()
|
|
||||||
pending := h.passkeyPending[req.SessionID]
|
|
||||||
h.passkeyMu.Unlock()
|
|
||||||
session, ok := h.takePasskeyCeremony(req.SessionID, "login", origin, pending.userID)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
|
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, err := h.loadPasskeyUser(pending.userID)
|
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
if err != nil {
|
// The credential ID determines ownership. A supplied userHandle cannot select another account.
|
||||||
response.WriteJSON(w, response.ErrDefault("账号不可用"))
|
if len(rawID) == 0 || len(userHandle) != 8 {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
credential, err := wa.FinishLogin(u, session, credentialRequest(r, req.Credential))
|
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
|
||||||
if err != nil || credential.Authenticator.CloneWarning {
|
|
||||||
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,21 +9,48 @@ import (
|
|||||||
|
|
||||||
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
|
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
|
||||||
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
|
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
|
||||||
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
|
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 {
|
if !ok {
|
||||||
t.Fatal("could not create challenge")
|
t.Fatal("could not create challenge")
|
||||||
}
|
}
|
||||||
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); !ok {
|
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
|
||||||
t.Fatal("valid challenge was rejected")
|
t.Fatal("valid challenge was rejected")
|
||||||
}
|
}
|
||||||
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
|
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
|
||||||
t.Fatal("challenge was reusable")
|
t.Fatal("challenge was reusable")
|
||||||
}
|
}
|
||||||
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
|
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 {
|
if !ok {
|
||||||
t.Fatal("could not create expired challenge")
|
t.Fatal("could not create expired challenge")
|
||||||
}
|
}
|
||||||
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
|
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
|
||||||
t.Fatal("expired challenge was accepted")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,19 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
credentials, err := decodeRuntimeRelayAuth(runtime)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var targets []federationRuntimeTarget
|
var targets []federationRuntimeTarget
|
||||||
if strings.TrimSpace(runtime.Target) != "" {
|
if strings.TrimSpace(runtime.Target) != "" {
|
||||||
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
|
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := validateFederationRelayAuth(runtime.RelayAuth, credentials, targets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var chain map[string]interface{}
|
var chain map[string]interface{}
|
||||||
if runtime.Role == "middle" {
|
if runtime.Role == "middle" {
|
||||||
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
|
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
|
||||||
@@ -42,7 +49,7 @@ func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName)
|
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName, credentials)
|
||||||
runtime.Applied = 0
|
runtime.Applied = 0
|
||||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||||
|
|||||||
@@ -145,18 +145,20 @@ func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
|
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
|
||||||
agent := newCleanupAgent(t)
|
var failDesiredWrite atomic.Bool
|
||||||
runtime := roleRuntimeFixture(t, agent, "exit")
|
agent := newCleanupAgent(t, func(r *repo.Repository) {
|
||||||
callback := "test:fail-role-desired-write"
|
if err := r.DB().Callback().Update().Before("gorm:update").Register("test:fail-role-desired-write", func(tx *gorm.DB) {
|
||||||
if err := agent.h.repo.DB().Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
if failDesiredWrite.Load() && tx.Statement.Table == "peer_share_runtime" {
|
||||||
if tx.Statement.Table == "peer_share_runtime" {
|
tx.AddError(errors.New("desired-state write failed"))
|
||||||
tx.AddError(errors.New("desired-state write failed"))
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}); err != nil {
|
})
|
||||||
t.Fatal(err)
|
runtime := roleRuntimeFixture(t, agent, "exit")
|
||||||
}
|
failDesiredWrite.Store(true)
|
||||||
// This database is test-local; keep the callback registered through the
|
// Only the atomic flag changes while the WebSocket is running; GORM's
|
||||||
// websocket teardown so callback mutation cannot race node status writes.
|
// callback registry stays immutable through node-status teardown writes.
|
||||||
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
|
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
|
||||||
t.Fatal("failed ownership persistence reported success")
|
t.Fatal("failed ownership persistence reported success")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/http/client"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) resolveTunnelRelayAuth(tx *gorm.DB, state *tunnelCreateState, create bool) error {
|
||||||
|
if state.Type != 2 || state.RelayAuthMode == relaysecurity.Legacy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if state.RelayAuthMode != relaysecurity.Required {
|
||||||
|
return fmt.Errorf("unsupported relay authentication mode")
|
||||||
|
}
|
||||||
|
for i := range state.OutNodes {
|
||||||
|
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.OutNodes[i].NodeID, 3, create)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.OutNodes[i].RelayAuth = c
|
||||||
|
}
|
||||||
|
for hop := range state.ChainHops {
|
||||||
|
for i := range state.ChainHops[hop] {
|
||||||
|
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.ChainHops[hop][i].NodeID, 2, create)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.ChainHops[hop][i].RelayAuth = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveBestExitRelayAuth(tunnelID int64, targets []tunnelRuntimeNode) error {
|
||||||
|
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tunnel == nil {
|
||||||
|
return fmt.Errorf("tunnel not found")
|
||||||
|
}
|
||||||
|
return h.resolveTunnelRelayAuth(nil, &tunnelCreateState{TunnelID: tunnelID, Type: tunnel.Type, RelayAuthMode: tunnel.RelayAuthMode, OutNodes: targets}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayAuthVersion(c *relaysecurity.Credentials) int {
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.Version
|
||||||
|
}
|
||||||
|
func decodePersistedRelayAuth(raw string) (*relaysecurity.Credentials, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var c relaysecurity.Credentials
|
||||||
|
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid persisted relay authentication")
|
||||||
|
}
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
func validateFederationRelayAuth(persisted string, c *relaysecurity.Credentials, targets []federationRuntimeTarget) error {
|
||||||
|
old, err := decodePersistedRelayAuth(persisted)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if old != nil && (c == nil || *old != *c) {
|
||||||
|
return fmt.Errorf("authenticated relay cannot be downgraded or rotated by reapply")
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, target := range targets {
|
||||||
|
if err := target.RelayAuth.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, target := range targets {
|
||||||
|
if target.RelayAuth != nil {
|
||||||
|
if err := target.RelayAuth.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) preflightFederationRelayAuth(state *tunnelCreateState, domain string) error {
|
||||||
|
if state.RelayAuthMode != relaysecurity.Required || state.Type != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, node := range state.Nodes {
|
||||||
|
if node == nil || node.IsRemote != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := client.NewFederationClient().Connect(node.RemoteURL, node.RemoteToken, domain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("relay authentication capability check failed for remote node %d", node.ID)
|
||||||
|
}
|
||||||
|
if info.RelayAuthVersion != relaysecurity.Version {
|
||||||
|
return fmt.Errorf("remote node %d does not support required relay authentication", node.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeRelayAuth(runtime *repo.PeerShareRuntime) (*relaysecurity.Credentials, error) {
|
||||||
|
c, err := decodePersistedRelayAuth(runtime.RelayAuth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if runtime.RelayAuthVersion != relayAuthVersion(c) {
|
||||||
|
return nil, fmt.Errorf("persisted relay authentication version or credentials unavailable")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/panelupgrade"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -75,6 +76,7 @@ type systemUpgradeCheckData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type systemUpgradeRunData struct {
|
type systemUpgradeRunData struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ComposeAsset string `json:"composeAsset"`
|
ComposeAsset string `json:"composeAsset"`
|
||||||
@@ -118,17 +120,7 @@ func validateBackendContainerName(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateUpgradeVersion(value string) error {
|
func validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) }
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return fmt.Errorf("upgrade version is empty")
|
|
||||||
}
|
|
||||||
for _, r := range value {
|
|
||||||
if r < 0x20 || r == 0x7f {
|
|
||||||
return fmt.Errorf("unsafe upgrade version: contains control character")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) composePath() string {
|
func (e *systemUpgradeExecutor) composePath() string {
|
||||||
return filepath.Join(e.deployDir, "docker-compose.yml")
|
return filepath.Join(e.deployDir, "docker-compose.yml")
|
||||||
@@ -136,6 +128,8 @@ func (e *systemUpgradeExecutor) composePath() string {
|
|||||||
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
|
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
|
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
reasons := make([]string, 0)
|
reasons := make([]string, 0)
|
||||||
if !filepath.IsAbs(e.deployDir) {
|
if !filepath.IsAbs(e.deployDir) {
|
||||||
reasons = append(reasons, "部署目录必须是绝对路径")
|
reasons = append(reasons, "部署目录必须是绝对路径")
|
||||||
@@ -143,8 +137,8 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
|||||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
||||||
reasons = append(reasons, err.Error())
|
reasons = append(reasons, err.Error())
|
||||||
}
|
}
|
||||||
if out, err := exec.CommandContext(ctx, "docker", "--version").CombinedOutput(); err != nil {
|
if err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil {
|
||||||
reasons = append(reasons, fmt.Sprintf("docker CLI不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
reasons = append(reasons, "docker CLI不可用")
|
||||||
}
|
}
|
||||||
if info, err := os.Stat(dockerSocketPath); err != nil {
|
if info, err := os.Stat(dockerSocketPath); err != nil {
|
||||||
reasons = append(reasons, "docker socket不可用: "+err.Error())
|
reasons = append(reasons, "docker socket不可用: "+err.Error())
|
||||||
@@ -161,10 +155,10 @@ func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCap
|
|||||||
} else if info.IsDir() {
|
} else if info.IsDir() {
|
||||||
reasons = append(reasons, "部署.env不是文件")
|
reasons = append(reasons, "部署.env不是文件")
|
||||||
}
|
}
|
||||||
if out, err := exec.CommandContext(ctx, "docker", "compose", "version").CombinedOutput(); err != nil {
|
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
|
||||||
reasons = append(reasons, fmt.Sprintf("docker compose不可用: %v: %s", err, strings.TrimSpace(string(out))))
|
reasons = append(reasons, "docker compose不可用")
|
||||||
}
|
}
|
||||||
if _, err := e.currentBackendImage(ctx); err != nil {
|
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil {
|
||||||
reasons = append(reasons, err.Error())
|
reasons = append(reasons, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,144 +177,6 @@ func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
|
|||||||
return "docker-compose-v4.yml"
|
return "docker-compose-v4.yml"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) helperScript() string {
|
|
||||||
return `set -eu
|
|
||||||
LOGFILE="$PANEL_DEPLOY_DIR/upgrade.log"
|
|
||||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOGFILE"; }
|
|
||||||
|
|
||||||
cd "$PANEL_DEPLOY_DIR"
|
|
||||||
echo "" > "$LOGFILE"
|
|
||||||
log "开始面板升级"
|
|
||||||
log "工作目录: $(pwd)"
|
|
||||||
|
|
||||||
if [ ! -f docker-compose.yml ]; then
|
|
||||||
log "错误: docker-compose.yml 不存在"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
log "错误: .env 不存在"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "拉取新镜像..."
|
|
||||||
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
|
|
||||||
log "错误: 拉取镜像失败"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "等待旧容器释放资源..."
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
log "重启服务(force-recreate)..."
|
|
||||||
if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
|
|
||||||
log "错误: 重启服务失败"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "升级完成"
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) ([]string, error) {
|
|
||||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{
|
|
||||||
"run", "-d", "--rm", "--name", helperName,
|
|
||||||
"--volumes-from", e.backendContainer,
|
|
||||||
"-v", dockerSocketPath + ":" + dockerSocketPath,
|
|
||||||
"-e", panelDeployDirEnv + "=" + e.deployDir,
|
|
||||||
"--entrypoint", "/bin/sh", imageID,
|
|
||||||
"-c", e.helperScript(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error {
|
|
||||||
if err := validateUpgradeVersion(version); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mode, err := fileModeOrDefault(envPath, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(envPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lines := strings.Split(string(data), "\n")
|
|
||||||
replaced := false
|
|
||||||
for i, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "FLUX_VERSION=") {
|
|
||||||
lines[i] = "FLUX_VERSION=" + version
|
|
||||||
replaced = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !replaced {
|
|
||||||
trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n")
|
|
||||||
if trimmed == "" {
|
|
||||||
trimmed = "FLUX_VERSION=" + version
|
|
||||||
} else {
|
|
||||||
trimmed += "\nFLUX_VERSION=" + version
|
|
||||||
}
|
|
||||||
return writeFileWithMode(envPath, []byte(trimmed+"\n"), mode)
|
|
||||||
}
|
|
||||||
content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n"
|
|
||||||
return writeFileWithMode(envPath, []byte(content), mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) backupFile(path string) (string, error) {
|
|
||||||
mode, err := fileModeOrDefault(path, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
backupPath := path + ".upgrade.bak"
|
|
||||||
if err := writeFileWithMode(backupPath, data, mode); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return backupPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) restoreBackup(path string) error {
|
|
||||||
backupPath := path + ".upgrade.bak"
|
|
||||||
mode, err := fileModeOrDefault(backupPath, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(backupPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeFileWithMode(path, data, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) restoreUpgradeBackups(paths ...string) error {
|
|
||||||
var errs []string
|
|
||||||
for _, path := range paths {
|
|
||||||
if err := e.restoreBackup(path); err != nil {
|
|
||||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
|
|
||||||
if len(bytes.TrimSpace(data)) == 0 {
|
|
||||||
return fmt.Errorf("compose asset is empty")
|
|
||||||
}
|
|
||||||
mode, err := fileModeOrDefault(path, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeFileWithMode(path, data, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
|
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
|
||||||
enabled, proxyURL := h.getGithubProxyConfig()
|
enabled, proxyURL := h.getGithubProxyConfig()
|
||||||
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
|
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
|
||||||
@@ -383,55 +239,6 @@ func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (st
|
|||||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return info.Mode().Perm(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFileWithMode(path string, data []byte, mode os.FileMode) error {
|
|
||||||
if err := os.WriteFile(path, data, mode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Chmod(path, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) {
|
|
||||||
if err := validateBackendContainerName(e.backendContainer); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("inspect backend image failed: %v: %s", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
imageID := strings.TrimSpace(string(out))
|
|
||||||
if imageID == "" {
|
|
||||||
return "", fmt.Errorf("backend image id is empty")
|
|
||||||
}
|
|
||||||
return imageID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helperName string) (string, error) {
|
|
||||||
args, err := e.buildHelperRunArgs(imageID, helperName)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("start helper failed: %v: %s", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
containerID := strings.TrimSpace(string(out))
|
|
||||||
if containerID == "" {
|
|
||||||
containerID = helperName
|
|
||||||
}
|
|
||||||
return containerID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
|
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
|
||||||
url := h.buildSystemUpgradeDownloadURL(version, filename)
|
url := h.buildSystemUpgradeDownloadURL(version, filename)
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
@@ -485,16 +292,26 @@ func releasesForChannel(releases []githubRelease, channel string) []systemUpgrad
|
|||||||
|
|
||||||
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(body) > 4096 {
|
||||||
|
return fmt.Errorf("request too large")
|
||||||
|
}
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
return decoder.Decode(req)
|
if err := decoder.Decode(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if decoder.Decode(&extra) != io.EOF {
|
||||||
|
return fmt.Errorf("unexpected trailing JSON")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
|
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
|
||||||
@@ -595,63 +412,63 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageID, err := exec.currentBackendImage(r.Context())
|
|
||||||
if err != nil {
|
if err := validateUpgradeVersion(version); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
composeData, err := os.ReadFile(exec.composePath())
|
||||||
composePath := exec.composePath()
|
|
||||||
envPath := exec.envPath()
|
|
||||||
composeData, err := os.ReadFile(composePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, "读取compose失败: "+err.Error()))
|
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
composeAsset := exec.selectComposeAsset(composeData)
|
composeAsset := exec.selectComposeAsset(composeData)
|
||||||
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := exec.backupFile(composePath); err != nil {
|
// A detached helper must not be canceled when the browser disconnects.
|
||||||
response.WriteJSON(w, response.Err(-2, "备份compose失败: "+err.Error()))
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
return
|
defer cancel()
|
||||||
}
|
job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose)
|
||||||
if _, err := exec.backupFile(envPath); err != nil {
|
|
||||||
response.WriteJSON(w, response.Err(-2, "备份.env失败: "+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := exec.replaceCompose(composePath, newCompose); err != nil {
|
|
||||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
|
||||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
|
||||||
}
|
|
||||||
response.WriteJSON(w, response.Err(-2, "替换compose失败: "+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
|
||||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
|
||||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
|
||||||
}
|
|
||||||
response.WriteJSON(w, response.Err(-2, "更新版本配置失败: "+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix())
|
|
||||||
helperContainer, err := exec.startHelper(r.Context(), imageID, helperName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if restoreErr := exec.restoreUpgradeBackups(composePath, envPath); restoreErr != nil {
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
err = fmt.Errorf("%v; 回滚失败: %v", err, restoreErr)
|
|
||||||
}
|
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage}))
|
||||||
response.WriteJSON(w, response.OK(systemUpgradeRunData{
|
}
|
||||||
Version: version,
|
|
||||||
Channel: channel,
|
func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
ComposeAsset: composeAsset,
|
if r.Method != http.MethodPost {
|
||||||
HelperContainer: helperContainer,
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
BackendImageID: imageID,
|
return
|
||||||
Message: systemUpgradeMessage,
|
}
|
||||||
}))
|
var req struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 4097))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&req); err != nil && err != io.EOF {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if decoder.Decode(&extra) != io.EOF {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := newSystemUpgradeExecutor()
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OK(struct {
|
||||||
|
CurrentVersion string `json:"currentVersion"`
|
||||||
|
Job *panelupgrade.Job `json:"job"`
|
||||||
|
}{currentPanelVersion(), job}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -128,149 +127,12 @@ func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
envPath := filepath.Join(dir, ".env")
|
|
||||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
|
||||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(envPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n"
|
|
||||||
if string(data) != want {
|
|
||||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
envPath := filepath.Join(dir, ".env")
|
|
||||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
|
||||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(envPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n"
|
|
||||||
if string(data) != want {
|
|
||||||
t.Fatalf("env content = %q, want %q", string(data), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateEnvVersionRejectsUnsafeValue(t *testing.T) {
|
|
||||||
for _, version := range []string{"", "2.1.9\nJWT_SECRET=bad", "2.1.9\rbad", "2.1.9\x00bad", "2.1.9\x1fbad"} {
|
|
||||||
t.Run(version, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
envPath := filepath.Join(dir, ".env")
|
|
||||||
original := []byte("JWT_SECRET=test\n")
|
|
||||||
if err := os.WriteFile(envPath, original, 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if err := exec.updateEnvVersion(envPath, version); err == nil {
|
|
||||||
t.Fatal("expected unsafe version to fail validation")
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(envPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != string(original) {
|
|
||||||
t.Fatalf("env content changed to %q, want %q", string(data), string(original))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateEnvVersionAcceptsVersionLabels(t *testing.T) {
|
|
||||||
for _, version := range []string{"2.1.9", "2.1.9-beta14", "v-test"} {
|
|
||||||
t.Run(version, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
envPath := filepath.Join(dir, ".env")
|
|
||||||
if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if err := exec.updateEnvVersion(envPath, version); err != nil {
|
|
||||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateEnvVersionPreservesFileMode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
envPath := filepath.Join(dir, ".env")
|
|
||||||
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil {
|
|
||||||
t.Fatalf("updateEnvVersion() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(envPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stat() error = %v", err)
|
|
||||||
}
|
|
||||||
if got := info.Mode().Perm(); got != 0o600 {
|
|
||||||
t.Fatalf("env mode = %o, want 0600", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
|
||||||
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
|
||||||
t.Fatal("expected unsafe container name to fail validation")
|
t.Fatal("expected unsafe container name to fail validation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) {
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
|
|
||||||
args, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("buildHelperRunArgs() error = %v", err)
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"run", "-d", "--rm", "--name", "flvx-upgrade-helper",
|
|
||||||
"--volumes-from", "flux-panel-backend",
|
|
||||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
|
||||||
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
|
|
||||||
"--entrypoint", "/bin/sh", "sha256:abc",
|
|
||||||
"-c", exec.helperScript(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !reflect.DeepEqual(args, want) {
|
|
||||||
t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildHelperRunArgsRejectsUnsafeBackendContainer(t *testing.T) {
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend;rm -rf /"}
|
|
||||||
if _, err := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper"); err == nil {
|
|
||||||
t.Fatal("expected unsafe backend container name to fail validation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
|
||||||
h := &Handler{}
|
h := &Handler{}
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
|
||||||
@@ -350,64 +212,6 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpgradeBackupUsesStablePathAndRestoreRestoresOriginal(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "docker-compose.yml")
|
|
||||||
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
backupPath, err := exec.backupFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backupFile() error = %v", err)
|
|
||||||
}
|
|
||||||
if backupPath != path+".upgrade.bak" {
|
|
||||||
t.Fatalf("backup path = %q, want %q", backupPath, path+".upgrade.bak")
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte("mutated"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := exec.restoreBackup(path); err != nil {
|
|
||||||
t.Fatalf("restoreBackup() error = %v", err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadFile() error = %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != "original" {
|
|
||||||
t.Fatalf("restored content = %q, want original", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestoreBackupPreservesOriginalFileMode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, ".env")
|
|
||||||
if err := os.WriteFile(path, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"}
|
|
||||||
if _, err := exec.backupFile(path); err != nil {
|
|
||||||
t.Fatalf("backupFile() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Remove(path); err != nil {
|
|
||||||
t.Fatalf("Remove() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := exec.restoreBackup(path); err != nil {
|
|
||||||
t.Fatalf("restoreBackup() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Stat() error = %v", err)
|
|
||||||
}
|
|
||||||
if got := info.Mode().Perm(); got != 0o600 {
|
|
||||||
t.Fatalf("restored mode = %o, want 0600", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
|
||||||
var payload systemUpgradeRequest
|
var payload systemUpgradeRequest
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
|
||||||
|
unlock := h.lockTunnelLifecycle(tunnelID, targetTunnelID)
|
||||||
|
defer unlock()
|
||||||
preview, err := h.buildTunnelDeletePreview(tunnelID)
|
preview, err := h.buildTunnelDeletePreview(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||||
@@ -431,9 +433,14 @@ func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
|
||||||
h.rollbackTunnelForwardMigrationPlans(plans)
|
var pending *tunnelDeletionCleanupPendingError
|
||||||
_ = h.redeployTunnelAndForwards(tunnelID)
|
if errors.As(err, &pending) {
|
||||||
return tunnelDeleteWithForwardsResult{}, nil, err
|
warnings = append(warnings, err.Error())
|
||||||
|
} else {
|
||||||
|
h.rollbackTunnelForwardMigrationPlans(plans)
|
||||||
|
_ = h.redeployTunnelAndForwardsLocked(tunnelID)
|
||||||
|
return tunnelDeleteWithForwardsResult{}, nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.MigratedCount = len(plans)
|
result.MigratedCount = len(plans)
|
||||||
@@ -639,8 +646,9 @@ func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigra
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
|
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
|
||||||
h.cleanupTunnelRuntime(tunnelID)
|
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
|
||||||
h.cleanupFederationRuntime(tunnelID)
|
return err
|
||||||
|
}
|
||||||
if err := h.deleteTunnelByID(tunnelID); err != nil {
|
if err := h.deleteTunnelByID(tunnelID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tunnelLifecycleLock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
users int
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockTunnelLifecycle serializes snapshots, persistence and node acknowledgements.
|
||||||
|
// Multiple tunnels (forward migration) are locked in ID order to avoid deadlocks.
|
||||||
|
// Callers must not acquire these locks recursively or hold a database transaction.
|
||||||
|
func (h *Handler) lockTunnelLifecycle(ids ...int64) func() {
|
||||||
|
unique := make(map[int64]bool, len(ids))
|
||||||
|
ordered := make([]int64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id > 0 && !unique[id] {
|
||||||
|
unique[id] = true
|
||||||
|
ordered = append(ordered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
|
||||||
|
locks := make([]*tunnelLifecycleLock, 0, len(ordered))
|
||||||
|
for _, id := range ordered {
|
||||||
|
h.tunnelLifecycleMu.Lock()
|
||||||
|
if h.tunnelLifecycleLocks == nil {
|
||||||
|
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
|
||||||
|
}
|
||||||
|
lock := h.tunnelLifecycleLocks[id]
|
||||||
|
if lock == nil {
|
||||||
|
lock = &tunnelLifecycleLock{}
|
||||||
|
h.tunnelLifecycleLocks[id] = lock
|
||||||
|
}
|
||||||
|
lock.users++
|
||||||
|
h.tunnelLifecycleMu.Unlock()
|
||||||
|
lock.mu.Lock()
|
||||||
|
locks = append(locks, lock)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
for i := len(locks) - 1; i >= 0; i-- {
|
||||||
|
h.tunnelLifecycleMu.Lock()
|
||||||
|
locks[i].mu.Unlock()
|
||||||
|
locks[i].users--
|
||||||
|
if locks[i].users == 0 {
|
||||||
|
delete(h.tunnelLifecycleLocks, ordered[i])
|
||||||
|
}
|
||||||
|
h.tunnelLifecycleMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probes run without the lifecycle lock. Recheck their topology under the lock
|
||||||
|
// so a late result cannot restore a removed middle hop or deleted tunnel.
|
||||||
|
func (h *Handler) validateBestExitSnapshot(tunnelID, ownerNodeID int64, exits []chainNodeRecord, ipPreference string) error {
|
||||||
|
tunnel, err := h.getTunnelRecord(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tunnel.Type != 2 || h.repo.GetTunnelIPPreference(tunnelID) != ipPreference {
|
||||||
|
return errors.New("best exit topology changed; retry next probe")
|
||||||
|
}
|
||||||
|
rows, err := h.listChainNodesForTunnel(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ins, hops, currentExits := splitChainNodeGroups(rows)
|
||||||
|
if !slices.Equal(exits, currentExits) {
|
||||||
|
return errors.New("best exit targets changed; retry next probe")
|
||||||
|
}
|
||||||
|
for _, owner := range bestExitChainOwners(ins, hops) {
|
||||||
|
if owner.NodeID == ownerNodeID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("best exit owner changed; retry next probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly allocated ID is not visible yet. Claim it without waiting while the
|
||||||
|
// insert transaction is open: an overlapping request for that ID must not make
|
||||||
|
// creation wait on a lifecycle lock whose owner is waiting for this transaction.
|
||||||
|
func (h *Handler) tryLockNewTunnelLifecycle(id int64) (func(), bool) {
|
||||||
|
h.tunnelLifecycleMu.Lock()
|
||||||
|
defer h.tunnelLifecycleMu.Unlock()
|
||||||
|
if h.tunnelLifecycleLocks == nil {
|
||||||
|
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
|
||||||
|
}
|
||||||
|
if h.tunnelLifecycleLocks[id] != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
lock := &tunnelLifecycleLock{users: 1}
|
||||||
|
lock.mu.Lock()
|
||||||
|
h.tunnelLifecycleLocks[id] = lock
|
||||||
|
return func() {
|
||||||
|
h.tunnelLifecycleMu.Lock()
|
||||||
|
defer h.tunnelLifecycleMu.Unlock()
|
||||||
|
lock.mu.Unlock()
|
||||||
|
lock.users--
|
||||||
|
if lock.users == 0 {
|
||||||
|
delete(h.tunnelLifecycleLocks, id)
|
||||||
|
}
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending is not successful application. HTTP create/update retain their
|
||||||
|
// accepted-config contract, while redeploy returns this error to its batch result.
|
||||||
|
type tunnelRuntimePendingError struct{ cause error }
|
||||||
|
|
||||||
|
func (e *tunnelRuntimePendingError) Error() string { return "运行时待同步: " + e.cause.Error() }
|
||||||
|
func isTunnelRuntimePending(err error) bool {
|
||||||
|
var pending *tunnelRuntimePendingError
|
||||||
|
return errors.As(err, &pending)
|
||||||
|
}
|
||||||
|
func tunnelRuntimePendingResponse(tunnelID int64, err error) response.R {
|
||||||
|
message := "配置已保存,部分节点运行时待同步:" + err.Error()
|
||||||
|
out := response.OK(map[string]interface{}{"runtimeStatus": "pending", "tunnelId": tunnelID, "message": message})
|
||||||
|
out.Msg = message
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve locally allocated ports with the initial desired state, before any
|
||||||
|
// remote acknowledgment. Remote ports remain unallocated until their provider
|
||||||
|
// returns; the second transaction replaces these rows with the final bindings.
|
||||||
|
func (h *Handler) persistPreparedTunnelChainsTx(tx *gorm.DB, state *tunnelCreateState) error {
|
||||||
|
save := func(node tunnelRuntimeNode, kind string, inx int) error {
|
||||||
|
return h.repo.CreateChainTunnelTx(tx, state.TunnelID, kind, node.NodeID, sql.NullInt64{Int64: int64(node.Port), Valid: node.Port > 0}, defaultString(node.Strategy, "round"), inx, defaultString(node.Protocol, "tls"), node.ConnectIP)
|
||||||
|
}
|
||||||
|
for i, node := range state.InNodes {
|
||||||
|
if err := save(node, "1", i+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, node := range state.OutNodes {
|
||||||
|
if err := save(node, "3", i+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, hop := range state.ChainHops {
|
||||||
|
for _, node := range hop {
|
||||||
|
if err := save(node, "2", i+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redeployProgress struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type redeployItemResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnelBatchRedeployStream reports completed tunnels, not estimated runtime.
|
||||||
|
// A disconnected caller may have an in-flight mutation whose result it did not
|
||||||
|
// receive. Finish that mutation, but never start another or automatically retry.
|
||||||
|
func (h *Handler) tunnelBatchRedeployStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ids := idsFromBody(r, w)
|
||||||
|
if ids == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// idsFromBody sorts IDs; ignore duplicate selections so each tunnel runs once.
|
||||||
|
unique := ids[:0]
|
||||||
|
for _, id := range ids {
|
||||||
|
if len(unique) == 0 || unique[len(unique)-1] != id {
|
||||||
|
unique = append(unique, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids = unique
|
||||||
|
if len(ids) == 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := prepareDiagnosisStreamResponse(w)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
controller := http.NewResponseController(w)
|
||||||
|
emit := func(kind string, data interface{}) bool {
|
||||||
|
if r.Context().Err() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The server's WriteTimeout is an absolute request deadline. Renew only
|
||||||
|
// this stream's bounded write window as progress is emitted, so a long
|
||||||
|
// batch is not cut off despite making steady progress. Recorders and
|
||||||
|
// other non-network writers may not support deadlines.
|
||||||
|
if err := controller.SetWriteDeadline(time.Now().Add(2 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
event := diagnosisStreamEvent{Type: kind, Data: data, TS: time.Now().UnixMilli()}
|
||||||
|
if err := encoder.Encode(event); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Buffered writes can succeed while the actual socket flush fails.
|
||||||
|
// Do not start a mutation after its processing event failed to flush.
|
||||||
|
return controller.Flush() == nil
|
||||||
|
}
|
||||||
|
progress := redeployProgress{Total: len(ids)}
|
||||||
|
result := batchOperationResult{}
|
||||||
|
if !emit("start", progress) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if r.Context().Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := h.repo.GetTunnelName(id)
|
||||||
|
if !emit("processing", map[string]interface{}{"id": id, "name": name, "progress": progress}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Context().Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := redeployItemResult{ID: id, Name: name}
|
||||||
|
if err := h.redeployTunnelConfiguration(id); err != nil {
|
||||||
|
result.FailCount++
|
||||||
|
result.Failures = appendBatchFailure(result.Failures, id, name, err)
|
||||||
|
item.Reason = result.Failures[len(result.Failures)-1].Reason
|
||||||
|
progress.Failed++
|
||||||
|
} else {
|
||||||
|
result.SuccessCount++
|
||||||
|
item.Success = true
|
||||||
|
progress.Success++
|
||||||
|
}
|
||||||
|
progress.Completed++
|
||||||
|
if !emit("item", map[string]interface{}{"result": item, "progress": progress}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("done", map[string]interface{}{"progress": progress, "result": result})
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/http/middleware"
|
||||||
|
runtimenft "go-backend/internal/runtime/nftables"
|
||||||
|
)
|
||||||
|
|
||||||
|
type blockingRedeployManager struct {
|
||||||
|
fakeNftablesManager
|
||||||
|
entered chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *blockingRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
|
||||||
|
m.once.Do(func() { close(m.entered); <-m.release })
|
||||||
|
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamFlushesBeforeSlowMutationAndReportsMixedResults(t *testing.T) {
|
||||||
|
fixture := setupNftablesHandler(t)
|
||||||
|
h := fixture.handler
|
||||||
|
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||||
|
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
|
||||||
|
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
h.nftablesManager = manager
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
release := func() { releaseOnce.Do(func() { close(manager.release) }) }
|
||||||
|
defer release()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(h.tunnelBatchRedeployStream))
|
||||||
|
defer server.Close()
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Post(server.URL, "application/json", strings.NewReader(fmt.Sprintf(`{"ids":[%d,999999,%d]}`, id, id)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
decoder := json.NewDecoder(response.Body)
|
||||||
|
read := func(want string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var event struct {
|
||||||
|
Type string
|
||||||
|
Data map[string]interface{}
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&event); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event.Type != want {
|
||||||
|
t.Fatalf("expected %s, got %s", want, event.Type)
|
||||||
|
}
|
||||||
|
return event.Data
|
||||||
|
}
|
||||||
|
if start := read("start"); start["total"] != float64(2) {
|
||||||
|
t.Fatalf("duplicate IDs not deduplicated: %+v", start)
|
||||||
|
}
|
||||||
|
processing := read("processing")
|
||||||
|
if processing["name"] != "stream-tunnel" || processing["id"] != float64(id) {
|
||||||
|
t.Fatalf("wrong current tunnel: %+v", processing)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-manager.entered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("did not start mutation")
|
||||||
|
}
|
||||||
|
// Both events arrived over HTTP while the actual mutation is still blocked.
|
||||||
|
release()
|
||||||
|
item := read("item")
|
||||||
|
if item["result"].(map[string]interface{})["success"] != true || item["progress"].(map[string]interface{})["completed"] != float64(1) {
|
||||||
|
t.Fatalf("wrong success result: %+v", item)
|
||||||
|
}
|
||||||
|
read("processing")
|
||||||
|
item = read("item")
|
||||||
|
if item["result"].(map[string]interface{})["success"] != false || item["result"].(map[string]interface{})["reason"] == "" {
|
||||||
|
t.Fatalf("wrong failure result: %+v", item)
|
||||||
|
}
|
||||||
|
done := read("done")
|
||||||
|
progress := done["progress"].(map[string]interface{})
|
||||||
|
result := done["result"].(map[string]interface{})
|
||||||
|
if progress["completed"] != float64(2) || progress["success"] != float64(1) || progress["failed"] != float64(1) || result["successCount"] != float64(1) || result["failCount"] != float64(1) {
|
||||||
|
t.Fatalf("incorrect final result: %+v", done)
|
||||||
|
}
|
||||||
|
if manager.reconcileCount() != 1 {
|
||||||
|
t.Fatalf("expected one execution, got %d", manager.reconcileCount())
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(new(interface{})); err != io.EOF {
|
||||||
|
t.Fatalf("expected EOF after done, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamStopsAfterDisconnect(t *testing.T) {
|
||||||
|
fixture := setupNftablesHandler(t)
|
||||||
|
h := fixture.handler
|
||||||
|
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||||
|
first := seedTunnelForNftables(t, h, "first", fixture.nodeID)
|
||||||
|
second := seedTunnelForNftables(t, h, "second", fixture.nodeID)
|
||||||
|
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
|
||||||
|
h.nftablesManager = manager
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(fmt.Sprintf(`{"ids":[%d,%d]}`, first, second))).WithContext(ctx)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
finished := make(chan struct{})
|
||||||
|
go func() { defer close(finished); h.tunnelBatchRedeployStream(res, req) }()
|
||||||
|
select {
|
||||||
|
case <-manager.entered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
close(manager.release)
|
||||||
|
t.Fatal("mutation did not start")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
close(manager.release)
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("handler did not stop")
|
||||||
|
}
|
||||||
|
if manager.reconcileCount() != 1 {
|
||||||
|
t.Fatalf("started another mutation after disconnect: %d", manager.reconcileCount())
|
||||||
|
}
|
||||||
|
if strings.Contains(res.Body.String(), `"type":"done"`) {
|
||||||
|
t.Fatal("disconnected stream claimed completion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failedRedeployWriter struct{ *httptest.ResponseRecorder }
|
||||||
|
|
||||||
|
func (w failedRedeployWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamDoesNotMutateWhenInitialWriteFails(t *testing.T) {
|
||||||
|
fixture := setupNftablesHandler(t)
|
||||||
|
h := fixture.handler
|
||||||
|
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||||
|
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
|
||||||
|
manager := &fakeNftablesManager{}
|
||||||
|
h.nftablesManager = manager
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
|
||||||
|
h.tunnelBatchRedeployStream(failedRedeployWriter{httptest.NewRecorder()}, req)
|
||||||
|
if manager.reconcileCount() != 0 {
|
||||||
|
t.Fatal("mutation ran despite failed progress stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type delayedRedeployManager struct{ fakeNftablesManager }
|
||||||
|
|
||||||
|
func (m *delayedRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
type shortRedeployDeadlineWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
deadlineCalls *atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w shortRedeployDeadlineWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||||
|
func (w shortRedeployDeadlineWriter) SetWriteDeadline(deadline time.Time) error {
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining < time.Minute || remaining > 2*time.Minute {
|
||||||
|
return fmt.Errorf("expected bounded two-minute write window, got %v", remaining)
|
||||||
|
}
|
||||||
|
w.deadlineCalls.Add(1)
|
||||||
|
// Shorten the production idle window for a real, fast timeout regression.
|
||||||
|
return http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamRenewsDeadlineThroughRequestLog(t *testing.T) {
|
||||||
|
fixture := setupNftablesHandler(t)
|
||||||
|
h := fixture.handler
|
||||||
|
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||||
|
ids := make([]int64, 3)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = seedTunnelForNftables(t, h, fmt.Sprintf("slow-%d", i), fixture.nodeID)
|
||||||
|
}
|
||||||
|
manager := &delayedRedeployManager{}
|
||||||
|
h.nftablesManager = manager
|
||||||
|
var deadlineCalls atomic.Int32
|
||||||
|
logged := middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream))
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logged.ServeHTTP(shortRedeployDeadlineWriter{w, &deadlineCalls}, r)
|
||||||
|
}))
|
||||||
|
server.Config.WriteTimeout = 50 * time.Millisecond
|
||||||
|
server.Start()
|
||||||
|
defer server.Close()
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"ids": ids})
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Post(server.URL, "application/json", strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
payload, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stream timed out across batch: %v (body %s)", err, payload)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(payload), `"type":"done"`) {
|
||||||
|
t.Fatalf("batch did not finish: %s", payload)
|
||||||
|
}
|
||||||
|
if manager.reconcileCount() != 3 {
|
||||||
|
t.Fatalf("wrong execution count: %d", manager.reconcileCount())
|
||||||
|
}
|
||||||
|
if deadlineCalls.Load() != 8 {
|
||||||
|
t.Fatalf("each of 8 events must renew its deadline, got %d", deadlineCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type flushFailureRedeployWriter struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
flushes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *flushFailureRedeployWriter) FlushError() error {
|
||||||
|
w.flushes++
|
||||||
|
if w.flushes == 2 {
|
||||||
|
return io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
w.ResponseRecorder.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamStopsBeforeMutationOnProcessingFlushError(t *testing.T) {
|
||||||
|
fixture := setupNftablesHandler(t)
|
||||||
|
h := fixture.handler
|
||||||
|
seedNftablesSSHConfig(t, h, fixture.nodeID)
|
||||||
|
id := seedTunnelForNftables(t, h, "flush-error", fixture.nodeID)
|
||||||
|
manager := &fakeNftablesManager{}
|
||||||
|
h.nftablesManager = manager
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
|
||||||
|
writer := &flushFailureRedeployWriter{ResponseRecorder: httptest.NewRecorder()}
|
||||||
|
middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream)).ServeHTTP(writer, req)
|
||||||
|
if writer.flushes != 2 {
|
||||||
|
t.Fatalf("expected start and processing flush, got %d", writer.flushes)
|
||||||
|
}
|
||||||
|
if manager.reconcileCount() != 0 {
|
||||||
|
t.Fatal("mutation ran after failed processing flush")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tunnelStateRuntimeRows(state *tunnelCreateState) []chainNodeRecord {
|
||||||
|
if state.Type != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []chainNodeRecord
|
||||||
|
for _, n := range state.InNodes {
|
||||||
|
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 1})
|
||||||
|
}
|
||||||
|
for _, hop := range state.ChainHops {
|
||||||
|
for _, n := range hop {
|
||||||
|
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 2, Port: n.Port})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range state.OutNodes {
|
||||||
|
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 3, Port: n.Port})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
func (h *Handler) queueObsoleteTunnelRuntimeTx(tx *gorm.DB, tunnelID int64, oldRows, newRows []chainNodeRecord) error {
|
||||||
|
var items []model.TunnelRuntimeCleanup
|
||||||
|
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
|
||||||
|
port := 0
|
||||||
|
for _, row := range oldRows {
|
||||||
|
if row.NodeID == nodeID {
|
||||||
|
port = row.Port
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "service", Port: port})
|
||||||
|
}
|
||||||
|
for _, old := range oldRows {
|
||||||
|
if !tunnelRuntimeNeedsService(old) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, current := range newRows {
|
||||||
|
if current.NodeID == old.NodeID && tunnelRuntimeNeedsService(current) && (current.Port != old.Port || current.ChainType != old.ChainType) {
|
||||||
|
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: old.NodeID, Kind: "service", Port: old.Port})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
|
||||||
|
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "chain"})
|
||||||
|
}
|
||||||
|
return h.repo.QueueTunnelRuntimeCleanupTx(tx, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds the tunnel lifecycle lock. Recheck current desired roles before
|
||||||
|
// every retry so a delayed deletion cannot remove a re-added runtime.
|
||||||
|
func (h *Handler) retryTunnelRuntimeCleanupLocked(tunnelID int64) error {
|
||||||
|
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var rows []chainNodeRecord
|
||||||
|
if tunnel != nil && tunnel.Type == 2 {
|
||||||
|
rows, err = h.listChainNodesForTunnel(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var failures []error
|
||||||
|
liveServices := map[int64]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
desired := false
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.NodeID == item.NodeID && ((item.Kind == "service" && tunnelRuntimeNeedsService(row)) || (item.Kind == "chain" && tunnelRuntimeNeedsChain(row))) {
|
||||||
|
desired = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if desired && item.Kind == "service" {
|
||||||
|
// Desired state does not prove that the old receiver was replaced.
|
||||||
|
// Keep this barrier (even at the same port) and protect its old
|
||||||
|
// chain until acknowledgeTunnelServiceReplacements observes ACK.
|
||||||
|
liveServices[item.NodeID] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !desired {
|
||||||
|
if item.Kind == "chain" && liveServices[item.NodeID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var commandErr error
|
||||||
|
if item.Kind == "service" {
|
||||||
|
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": tunnelRuntimeServiceNames(tunnelID)}, false, true)
|
||||||
|
} else {
|
||||||
|
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteChains", map[string]interface{}{"chain": fmt.Sprintf("chains_%d", tunnelID)}, false, true)
|
||||||
|
}
|
||||||
|
if commandErr != nil {
|
||||||
|
if item.Kind == "service" {
|
||||||
|
liveServices[item.NodeID] = true
|
||||||
|
}
|
||||||
|
failures = append(failures, fmt.Errorf("节点 %d 旧运行时清理失败: %w", item.NodeID, commandErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
|
||||||
|
if item.Kind == "service" {
|
||||||
|
liveServices[item.NodeID] = true
|
||||||
|
}
|
||||||
|
failures = append(failures, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) acknowledgeTunnelServiceReplacements(tunnelID int64, nodeIDs []int64) error {
|
||||||
|
if len(nodeIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Kind == "service" {
|
||||||
|
for _, nodeID := range nodeIDs {
|
||||||
|
if item.NodeID == nodeID {
|
||||||
|
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (h *Handler) retryPendingTunnelRuntimeCleanup() error {
|
||||||
|
return h.retryPendingTunnelRuntimeCleanupOnNode(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) retryPendingTunnelRuntimeCleanupOnNode(nodeID int64) error {
|
||||||
|
items, err := h.repo.ListTunnelRuntimeCleanup(0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
var failures []error
|
||||||
|
for _, item := range items {
|
||||||
|
if nodeID > 0 && item.NodeID != nodeID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[item.TunnelID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.TunnelID] = true
|
||||||
|
unlock := h.lockTunnelLifecycle(item.TunnelID)
|
||||||
|
err := h.retryTunnelRuntimeCleanupLocked(item.TunnelID)
|
||||||
|
unlock()
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The desired tunnel has already been deleted; callers must not roll back a
|
||||||
|
// completed forward migration merely because asynchronous cleanup is pending.
|
||||||
|
type tunnelDeletionCleanupPendingError struct{ cause error }
|
||||||
|
|
||||||
|
func (e *tunnelDeletionCleanupPendingError) Error() string {
|
||||||
|
return "隧道配置已删除,运行时清理待重试: " + e.cause.Error()
|
||||||
|
}
|
||||||
|
func (e *tunnelDeletionCleanupPendingError) Unwrap() error { return e.cause }
|
||||||
@@ -411,6 +411,9 @@ func (h *Handler) onNodeOnline(nodeID int64) {
|
|||||||
|
|
||||||
// A fresh agent needs a full restore. Shared failures remain persisted and
|
// A fresh agent needs a full restore. Shared failures remain persisted and
|
||||||
// are retried by maintenance without restarting acknowledged services.
|
// are retried by maintenance without restarting acknowledged services.
|
||||||
|
if err := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID); err != nil {
|
||||||
|
fmt.Printf("reconnect obsolete tunnel cleanup remains pending: %v\n", err)
|
||||||
|
}
|
||||||
h.reconcileSharedNodeRuntime(nodeID)
|
h.reconcileSharedNodeRuntime(nodeID)
|
||||||
if !h.redeployLocalNodeRuntime(nodeID) {
|
if !h.redeployLocalNodeRuntime(nodeID) {
|
||||||
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
h.scheduleNodeLocalRuntimeRetry(nodeID)
|
||||||
@@ -568,9 +571,10 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
||||||
|
cleanupOK := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID) == nil
|
||||||
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
|
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
|
||||||
localOK := h.redeployLocalNodeRuntime(nodeID)
|
localOK := h.redeployLocalNodeRuntime(nodeID)
|
||||||
return sharedOK && localOK
|
return cleanupOK && sharedOK && localOK
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
|
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ func requiresAdmin(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch path {
|
switch path {
|
||||||
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||||
return true
|
return true
|
||||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ type statusWriter struct {
|
|||||||
status int
|
status int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap preserves ResponseController features such as per-response deadlines.
|
||||||
|
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
||||||
|
return w.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
func (w *statusWriter) WriteHeader(code int) {
|
func (w *statusWriter) WriteHeader(code int) {
|
||||||
w.status = code
|
w.status = code
|
||||||
w.ResponseWriter.WriteHeader(code)
|
w.ResponseWriter.WriteHeader(code)
|
||||||
@@ -27,10 +32,12 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|||||||
return hj.Hijack()
|
return hj.Hijack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) FlushError() error {
|
||||||
|
return http.NewResponseController(w.ResponseWriter).Flush()
|
||||||
|
}
|
||||||
|
|
||||||
func (w *statusWriter) Flush() {
|
func (w *statusWriter) Flush() {
|
||||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
_ = w.FlushError()
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
|
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Panel upgrade safety and recovery
|
||||||
|
|
||||||
|
The helper is a subcommand in the **currently running image**, dispatched before
|
||||||
|
application/database initialization. This fix must first be installed through the
|
||||||
|
normal administrator deployment procedure on older releases lacking the helper.
|
||||||
|
It cannot retroactively repair an already-running legacy upgrade helper.
|
||||||
|
|
||||||
|
Supported deployments have one `docker-compose.yml`, the original Compose project
|
||||||
|
labels, and a writable host bind mount containing that file and `.env`. The
|
||||||
|
helper mounts that directory at its **host absolute path** and passes explicit
|
||||||
|
project, project-directory, env-file and compose-file options. Multiple Compose
|
||||||
|
files, mismatched mounts/resources, or changed database settings fail closed.
|
||||||
|
|
||||||
|
Jobs are under `<deployment>/.panel-upgrades/<jobId>/` (directory 0700, files
|
||||||
|
0600). `*.original` contains immutable per-job configuration snapshots, not a live
|
||||||
|
database backup. `events.log` contains safe phase messages only. Legacy
|
||||||
|
`.upgrade.bak` files and `upgrade.log` are never changed. Helper containers remain
|
||||||
|
for diagnosis; they are intentionally not automatically removed. Operators may
|
||||||
|
remove completed helpers after retaining the job's files.
|
||||||
|
|
||||||
|
The `latest` pointer plus filesystem lock prevents simultaneous jobs across API
|
||||||
|
processes and backend replacement. Failed validation/pull leaves live files
|
||||||
|
untouched. A failed configuration application attempts to restore both files
|
||||||
|
before any recreate. Once `restarting` is recorded, failures require recovery:
|
||||||
|
no automatic database rollback, image rollback, or container rollback is safe.
|
||||||
|
The helper has a 30 minute overall deadline, including a maximum five minute
|
||||||
|
post-recreate verification window. Docker client timeout during recreation can
|
||||||
|
leave daemon work in flight; it is always treated as uncertain, not retried.
|
||||||
|
|
||||||
|
## Operator recovery after an interrupted/uncertain job
|
||||||
|
|
||||||
|
There is intentionally no remotely callable “clear lock” endpoint. Do not delete
|
||||||
|
job directories or rewrite status JSON to force another attempt.
|
||||||
|
|
||||||
|
1. Work on the Docker host and identify the job from `latest`, `status.json` and
|
||||||
|
`plan.json`. Preserve the entire job directory, live Compose/env files, and
|
||||||
|
container inspection information in a private location. These files can
|
||||||
|
contain credentials. Never paste `.env` or raw Compose config into public logs.
|
||||||
|
2. Restore Docker daemon access if necessary. Inspect the exact helper container
|
||||||
|
named by the job. If it is running, let it finish or explicitly stop it as an
|
||||||
|
administrator. Verify it has stopped. If launch was uncertain, establish that
|
||||||
|
no matching helper exists and no Docker create/up operation remains in flight.
|
||||||
|
Do not clear the job while any of these facts are uncertain.
|
||||||
|
3. Inspect the original project's backend and frontend container IDs, images,
|
||||||
|
mounts and health. Check the actual database and application logs privately.
|
||||||
|
After any attempted recreation, assume migrations may already have occurred.
|
||||||
|
Preserve a current database snapshot before further manual deployment work.
|
||||||
|
Use the normal documented deployment procedure to bring both services to a
|
||||||
|
consistent, healthy state. Do not copy an old database over the current one or
|
||||||
|
blindly redeploy an older image. Configuration snapshots alone cannot undo a
|
||||||
|
schema migration.
|
||||||
|
4. Only after verifying no helper/daemon work remains and both services and their
|
||||||
|
database are consistent, archive the `latest` pointer under a unique filename
|
||||||
|
in `.panel-upgrades/` (for example `resolved-<jobId>.pointer`). Do this while
|
||||||
|
the API is stopped, or hold an exclusive OS flock on `.panel-upgrades/lock`,
|
||||||
|
so no other start can race this manual recovery. Keep the job directory and
|
||||||
|
archived pointer. This permits a new job without changing recovery evidence.
|
||||||
|
5. Restart the API if stopped and reload the browser settings page so it discovers
|
||||||
|
the current `latest` task instead of following the resolved task ID. No current
|
||||||
|
job is expected. A fresh start creates a new ID and new backups, never
|
||||||
|
overwriting old evidence.
|
||||||
|
|
||||||
|
A lost helper, unavailable daemon, or stale status is displayed as
|
||||||
|
`recovery_required` while retaining the active lock. This is intentionally a
|
||||||
|
conservative derived status, so a helper that is still completing work can report
|
||||||
|
its actual durable result without racing a status request.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package panelupgrade
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperTimeout = 30 * time.Minute
|
||||||
|
|
||||||
|
var verificationTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
type composeConfig struct {
|
||||||
|
FullServices map[string]map[string]any `json:"-"`
|
||||||
|
Services map[string]struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
Volumes []map[string]any `json:"volumes"`
|
||||||
|
Environment map[string]string `json:"environment"`
|
||||||
|
} `json:"services"`
|
||||||
|
Volumes map[string]any `json:"volumes"`
|
||||||
|
Networks map[string]any `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeArgs(p Plan, dir string, args ...string) []string {
|
||||||
|
return append([]string{"compose", "--project-name", p.Project, "--project-directory", p.HostDir, "--env-file", filepath.Join(dir, ".env"), "-f", filepath.Join(dir, "docker-compose.yml")}, args...)
|
||||||
|
}
|
||||||
|
func config(ctx context.Context, p Plan, dir string) (composeConfig, error) {
|
||||||
|
var c composeConfig
|
||||||
|
b, e := docker(ctx, composeArgs(p, dir, "config", "--format", "json")...)
|
||||||
|
if e != nil {
|
||||||
|
return c, e
|
||||||
|
}
|
||||||
|
e = json.Unmarshal(b, &c)
|
||||||
|
if e == nil {
|
||||||
|
var raw struct {
|
||||||
|
Services map[string]map[string]any `json:"services"`
|
||||||
|
}
|
||||||
|
e = json.Unmarshal(b, &raw)
|
||||||
|
c.FullServices = raw.Services
|
||||||
|
}
|
||||||
|
return c, e
|
||||||
|
}
|
||||||
|
func validateConfig(old, next composeConfig, p Plan) error {
|
||||||
|
// Compare complete normalized services, not a projection that can silently drop
|
||||||
|
// host bindings, commands, security options, networks or dependency changes.
|
||||||
|
if old.FullServices == nil || next.FullServices == nil {
|
||||||
|
return errors.New("missing full Compose configuration")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(old.FullServices)
|
||||||
|
var expected map[string]map[string]any
|
||||||
|
if json.Unmarshal(raw, &expected) != nil {
|
||||||
|
return errors.New("invalid configuration")
|
||||||
|
}
|
||||||
|
for _, service := range []string{"backend", "frontend"} {
|
||||||
|
if expected[service] == nil || next.FullServices[service] == nil {
|
||||||
|
return errors.New("missing service")
|
||||||
|
}
|
||||||
|
expected[service]["image"] = next.FullServices[service]["image"]
|
||||||
|
}
|
||||||
|
if env, ok := expected["backend"]["environment"].(map[string]any); ok {
|
||||||
|
env["FLUX_VERSION"] = p.Job.TargetVersion
|
||||||
|
} else {
|
||||||
|
return errors.New("missing backend environment")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(expected, next.FullServices) {
|
||||||
|
return errors.New("unsupported service configuration change; manual upgrade required")
|
||||||
|
}
|
||||||
|
|
||||||
|
for service, image := range map[string]string{"backend": "ghcr.io/sagit-chu/flux-panel-backend:", "frontend": "ghcr.io/sagit-chu/vite-frontend:"} {
|
||||||
|
a, ok := old.Services[service]
|
||||||
|
b, present := next.Services[service]
|
||||||
|
if !ok || !present || b.Image != image+p.Job.TargetVersion || a.ContainerName != b.ContainerName || !reflect.DeepEqual(a.Volumes, b.Volumes) {
|
||||||
|
return errors.New("service image, container identity or persistent mounts do not match the supported deployment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.Services["backend"].ContainerName != p.Backend || next.Services["backend"].Environment["FLUX_VERSION"] != p.Job.TargetVersion {
|
||||||
|
return errors.New("backend identity/version mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
for service := range next.Services {
|
||||||
|
if service != "backend" && service != "frontend" && service != "postgres" {
|
||||||
|
return errors.New("unsupported service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range old.Services["backend"].Environment {
|
||||||
|
if key != "FLUX_VERSION" && next.Services["backend"].Environment[key] != value {
|
||||||
|
return errors.New("backend environment changed; manual upgrade required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range next.Services["backend"].Environment {
|
||||||
|
if key != "FLUX_VERSION" && old.Services["backend"].Environment[key] != value {
|
||||||
|
return errors.New("backend environment changed; manual upgrade required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Database configuration and shared resource identities are not upgrade targets.
|
||||||
|
if !reflect.DeepEqual(old.Services["postgres"], next.Services["postgres"]) || !reflect.DeepEqual(old.Volumes, next.Volumes) || !reflect.DeepEqual(old.Networks, next.Networks) {
|
||||||
|
return errors.New("database or shared resource configuration changed; manual upgrade required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateLive proves the normalized source configuration still describes the running
|
||||||
|
// backend. In particular, Compose must not attach a fresh database volume by accident.
|
||||||
|
func validateLive(ctx context.Context, p Plan, c composeConfig) error {
|
||||||
|
live, err := inspect(ctx, p.Backend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if live.Image != p.Image || !live.State.Running || live.Config.Labels["com.docker.compose.project"] != p.Project || live.Config.Labels["com.docker.compose.service"] != "backend" {
|
||||||
|
return errors.New("running backend changed")
|
||||||
|
}
|
||||||
|
if len(live.Mounts) != len(c.Services["backend"].Volumes) {
|
||||||
|
return errors.New("unexpected live mounts; manual upgrade required")
|
||||||
|
}
|
||||||
|
for _, v := range c.Services["backend"].Volumes {
|
||||||
|
typ, _ := v["type"].(string)
|
||||||
|
src, _ := v["source"].(string)
|
||||||
|
dst, _ := v["target"].(string)
|
||||||
|
if typ == "volume" {
|
||||||
|
spec, ok := c.Volumes[src].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unknown volume")
|
||||||
|
}
|
||||||
|
src, _ = spec["name"].(string)
|
||||||
|
}
|
||||||
|
if src == "" || dst == "" || (typ != "volume" && typ != "bind") {
|
||||||
|
return errors.New("unsupported mount")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, mount := range live.Mounts {
|
||||||
|
actual := mount.Source
|
||||||
|
if typ == "volume" {
|
||||||
|
actual = mount.Name
|
||||||
|
}
|
||||||
|
if mount.Type == typ && mount.Destination == dst && actual == src && mount.RW != (v["read_only"] == true) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return errors.New("live persistent mount does not match Compose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env := map[string]string{}
|
||||||
|
for _, v := range live.Config.Env {
|
||||||
|
parts := strings.SplitN(v, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
env[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"DB_TYPE", "DB_PATH", "DATABASE_URL", "PANEL_DEPLOY_DIR", "PANEL_BACKEND_CONTAINER"} {
|
||||||
|
if env[key] != c.Services["backend"].Environment[key] {
|
||||||
|
return errors.New("live database/deployment environment does not match Compose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunHelper runs before app/database initialization and does not depend on the old backend process.
|
||||||
|
func RunHelper(planPath string) error {
|
||||||
|
b, e := os.ReadFile(planPath)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var p Plan
|
||||||
|
if e = json.Unmarshal(b, &p); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if !idPattern.MatchString(p.Job.ID) || ValidateVersion(p.Job.TargetVersion) != nil || !projectPattern.MatchString(p.Project) {
|
||||||
|
return errors.New("invalid plan")
|
||||||
|
}
|
||||||
|
m := Manager{Dir: p.HostDir, Backend: p.Backend}
|
||||||
|
dir := m.jobDir(p.Job.ID)
|
||||||
|
if filepath.Clean(planPath) != filepath.Join(dir, "plan.json") {
|
||||||
|
return errors.New("invalid plan location")
|
||||||
|
}
|
||||||
|
// Reject duplicate helper invocations without affecting the live job.
|
||||||
|
f, e := os.OpenFile(filepath.Join(dir, "helper.started"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
j, e := m.read(p.Job.ID)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if !j.Active {
|
||||||
|
return errors.New("job is not active")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
|
||||||
|
defer cancel()
|
||||||
|
fail := func(msg string) error {
|
||||||
|
if e := m.write(j, "failed", msg); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
recovery := func(msg string) error {
|
||||||
|
m.write(j, "recovery_required", msg+";请管理员核对容器、数据库和任务备份后人工恢复,禁止自动回滚或重复升级")
|
||||||
|
return errors.New(msg)
|
||||||
|
}
|
||||||
|
if e = m.write(j, "preparing", "校验部署配置和持久化资源"); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
old, e := config(ctx, p, p.HostDir)
|
||||||
|
if e != nil {
|
||||||
|
return fail("现有 Compose 配置校验失败,线上配置未变更")
|
||||||
|
}
|
||||||
|
next, e := config(ctx, p, dir)
|
||||||
|
if e != nil {
|
||||||
|
return fail("目标 Compose 配置校验失败,线上配置未变更")
|
||||||
|
}
|
||||||
|
if validateConfig(old, next, p) != nil {
|
||||||
|
return fail("目标镜像、服务或持久化资源不匹配,需手动升级")
|
||||||
|
}
|
||||||
|
if validateLive(ctx, p, old) != nil {
|
||||||
|
return fail("运行中后端与部署配置不一致,需手动升级")
|
||||||
|
}
|
||||||
|
if e = m.write(j, "pulling", "正在拉取并检查目标镜像,线上配置尚未变更"); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if _, e = docker(ctx, composeArgs(p, dir, "pull", "backend", "frontend")...); e != nil {
|
||||||
|
return fail("镜像拉取失败或超时,线上配置未变更")
|
||||||
|
}
|
||||||
|
targets := map[string]string{}
|
||||||
|
for _, service := range []string{"backend", "frontend"} {
|
||||||
|
out, err := docker(ctx, "image", "inspect", "--format", "{{.Id}}", next.Services[service].Image)
|
||||||
|
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "sha256:") {
|
||||||
|
return fail("目标镜像校验失败,线上配置未变更")
|
||||||
|
}
|
||||||
|
targets[service] = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
if validateLive(ctx, p, old) != nil {
|
||||||
|
return fail("拉取期间运行中后端发生变化,已安全取消")
|
||||||
|
}
|
||||||
|
// Detect edits since snapshots before replacing either live file.
|
||||||
|
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||||
|
live, err := os.ReadFile(filepath.Join(p.HostDir, name))
|
||||||
|
if err != nil {
|
||||||
|
return fail("读取线上配置失败")
|
||||||
|
}
|
||||||
|
backup, err := os.ReadFile(filepath.Join(dir, name+".original"))
|
||||||
|
if err != nil || string(live) != string(backup) {
|
||||||
|
return fail("线上配置已被修改,本任务已安全取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e = m.write(j, "applying", "正在应用已校验配置"); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err == nil {
|
||||||
|
err = atomic(filepath.Join(p.HostDir, name), data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
for _, restore := range []string{"docker-compose.yml", ".env"} {
|
||||||
|
original, re := os.ReadFile(filepath.Join(dir, restore+".original"))
|
||||||
|
if re != nil || atomic(filepath.Join(p.HostDir, restore), original) != nil {
|
||||||
|
return recovery("配置恢复失败,尚未执行容器重建")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fail("应用配置失败,已恢复原配置,未执行容器重建")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist the point of no automatic rollback BEFORE Docker can recreate any service.
|
||||||
|
if e = m.write(j, "restarting", "正在重建面板服务"); e != nil {
|
||||||
|
return recovery("无法记录重建状态,已停止执行")
|
||||||
|
}
|
||||||
|
if _, e = docker(ctx, composeArgs(p, p.HostDir, "up", "-d", "--no-deps", "--force-recreate", "--pull", "never", "backend", "frontend")...); e != nil {
|
||||||
|
return recovery("容器重建失败或超时,可能已部分生效")
|
||||||
|
}
|
||||||
|
if e = m.write(j, "verifying", "正在验证服务版本及健康状态"); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(verificationTimeout)
|
||||||
|
for time.Now().Before(deadline) && ctx.Err() == nil {
|
||||||
|
ready := true
|
||||||
|
for _, service := range []string{"backend", "frontend"} {
|
||||||
|
c, err := inspect(ctx, next.Services[service].ContainerName)
|
||||||
|
if err != nil || !c.State.Running || c.Image != targets[service] || c.Config.Labels["com.docker.compose.project"] != p.Project || c.Config.Labels["com.docker.compose.service"] != service {
|
||||||
|
ready = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.State.Health != nil && c.State.Health.Status != "healthy" {
|
||||||
|
ready = false
|
||||||
|
}
|
||||||
|
if service == "backend" && c.State.Health == nil {
|
||||||
|
ready = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ready { // Frontend has no Docker healthcheck in the stock template.
|
||||||
|
probeCtx, probeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
_, probeErr := docker(probeCtx, "exec", next.Services["frontend"].ContainerName, "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:80/")
|
||||||
|
probeCancel()
|
||||||
|
ready = probeErr == nil
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
return m.write(j, "succeeded", "升级完成,目标镜像和服务健康状态已验证")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recovery("升级后服务健康检查失败或超时")
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// Package panelupgrade implements durable, conservative Docker Compose upgrades.
|
||||||
|
package panelupgrade
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`)
|
||||||
|
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
|
||||||
|
var projectPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||||
|
|
||||||
|
func ValidateVersion(v string) error {
|
||||||
|
if !versionPattern.MatchString(v) || len(v) > 128 {
|
||||||
|
return errors.New("invalid release version")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TargetVersion string `json:"targetVersion"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
RequiresRecovery bool `json:"requiresRecovery"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
StartedAt int64 `json:"startedAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
HelperContainer string `json:"helperContainer,omitempty"`
|
||||||
|
BackupDir string `json:"backupDir,omitempty"`
|
||||||
|
}
|
||||||
|
type Plan struct {
|
||||||
|
Job Job
|
||||||
|
DeployDir, HostDir, Project, Backend, Image string
|
||||||
|
}
|
||||||
|
type Manager struct{ Dir, Backend string }
|
||||||
|
|
||||||
|
func (m Manager) root() string { return filepath.Join(m.Dir, ".panel-upgrades") }
|
||||||
|
func (m Manager) jobDir(id string) string { return filepath.Join(m.root(), id) }
|
||||||
|
func atomic(path string, b []byte) error {
|
||||||
|
f, e := os.CreateTemp(filepath.Dir(path), ".write-")
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
n := f.Name()
|
||||||
|
defer os.Remove(n)
|
||||||
|
if e = f.Chmod(0600); e == nil {
|
||||||
|
_, e = f.Write(b)
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
e = f.Sync()
|
||||||
|
}
|
||||||
|
ce := f.Close()
|
||||||
|
if e == nil {
|
||||||
|
e = ce
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e = os.Rename(n, path); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
d, e := os.Open(filepath.Dir(path))
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
return d.Sync()
|
||||||
|
}
|
||||||
|
func save(path string, v any) error {
|
||||||
|
b, e := json.Marshal(v)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return atomic(path, b)
|
||||||
|
}
|
||||||
|
func (m Manager) lock() (func(), error) {
|
||||||
|
if e := os.MkdirAll(m.root(), 0700); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
f, e := os.OpenFile(filepath.Join(m.root(), "lock"), os.O_CREATE|os.O_RDWR, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, errors.New("another upgrade operation is in progress")
|
||||||
|
}
|
||||||
|
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
|
||||||
|
}
|
||||||
|
func docker(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
b, e := cmd.Output()
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("Docker operation failed (details suppressed to protect credentials)")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type container struct {
|
||||||
|
Image string
|
||||||
|
Config struct {
|
||||||
|
Labels map[string]string
|
||||||
|
Env []string
|
||||||
|
}
|
||||||
|
State struct {
|
||||||
|
Running bool
|
||||||
|
Health *struct{ Status string }
|
||||||
|
}
|
||||||
|
Mounts []struct {
|
||||||
|
Type, Source, Destination, Name string
|
||||||
|
RW bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(ctx context.Context, name string) (container, error) {
|
||||||
|
var c []container
|
||||||
|
b, e := docker(ctx, "inspect", name)
|
||||||
|
if e != nil {
|
||||||
|
return container{}, e
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &c) != nil || len(c) != 1 {
|
||||||
|
return container{}, errors.New("invalid Docker inspection")
|
||||||
|
}
|
||||||
|
return c[0], nil
|
||||||
|
}
|
||||||
|
func (m Manager) Identity(ctx context.Context) (Plan, error) {
|
||||||
|
p := Plan{DeployDir: m.Dir, Backend: m.Backend}
|
||||||
|
if !filepath.IsAbs(m.Dir) || filepath.Clean(m.Dir) != m.Dir {
|
||||||
|
return p, errors.New("deployment directory must be an absolute clean path")
|
||||||
|
}
|
||||||
|
c, e := inspect(ctx, m.Backend)
|
||||||
|
if e != nil {
|
||||||
|
return p, e
|
||||||
|
}
|
||||||
|
p.Image = c.Image
|
||||||
|
p.Project = c.Config.Labels["com.docker.compose.project"]
|
||||||
|
if !c.State.Running || !projectPattern.MatchString(p.Project) || c.Config.Labels["com.docker.compose.service"] != "backend" || !strings.HasPrefix(p.Image, "sha256:") {
|
||||||
|
return p, errors.New("cannot establish original Compose identity")
|
||||||
|
}
|
||||||
|
for _, v := range c.Mounts {
|
||||||
|
if v.Destination == m.Dir && v.Type == "bind" && v.RW {
|
||||||
|
p.HostDir = v.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.HostDir == "/" || !filepath.IsAbs(p.HostDir) || strings.ContainsAny(p.HostDir, ",\n\r") || filepath.Clean(p.HostDir) != p.HostDir {
|
||||||
|
return p, errors.New("deployment directory must be a writable host bind mount")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, protected := range []string{"/app/paneld", "/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins/docker-compose", "/var/run/docker.sock", "/bin", "/lib", "/etc", "/proc", "/sys", "/dev"} {
|
||||||
|
if p.HostDir == protected || strings.HasPrefix(protected, p.HostDir+"/") || strings.HasPrefix(p.HostDir, protected+"/") {
|
||||||
|
return p, errors.New("host deployment mount would shadow helper runtime paths")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Config.Labels["com.docker.compose.project.working_dir"] != p.HostDir || c.Config.Labels["com.docker.compose.project.config_files"] != filepath.Join(p.HostDir, "docker-compose.yml") {
|
||||||
|
return p, errors.New("custom or multiple Compose paths require manual upgrade")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
func (m Manager) read(id string) (*Job, error) {
|
||||||
|
if !idPattern.MatchString(id) {
|
||||||
|
return nil, errors.New("invalid job ID")
|
||||||
|
}
|
||||||
|
b, e := os.ReadFile(filepath.Join(m.jobDir(id), "status.json"))
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
var j Job
|
||||||
|
e = json.Unmarshal(b, &j)
|
||||||
|
return &j, e
|
||||||
|
}
|
||||||
|
func (m Manager) Status(ctx context.Context, id string) (*Job, error) {
|
||||||
|
if id == "" {
|
||||||
|
b, e := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||||
|
if os.IsNotExist(e) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
id = string(b)
|
||||||
|
}
|
||||||
|
j, e := m.read(id)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if !j.Active || time.Now().UnixMilli()-j.UpdatedAt < 90000 {
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
// An unavailable daemon, missing helper, or uncertain launch must never release the task lock.
|
||||||
|
c, e := inspect(ctx, j.HelperContainer)
|
||||||
|
if e == nil && c.State.Running {
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
j.RequiresRecovery = true
|
||||||
|
j.Message = "升级 helper 已停止或无法确认状态。请管理员检查任务备份和容器状态;在确认安全前禁止再次升级。"
|
||||||
|
// This is a derived view: do not race the helper's durable state writes.
|
||||||
|
j.Stage = "recovery_required"
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
func (m Manager) write(j *Job, stage, msg string) error {
|
||||||
|
j.Stage = stage
|
||||||
|
j.Message = msg
|
||||||
|
j.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
j.Active = stage != "succeeded" && stage != "failed"
|
||||||
|
j.RequiresRecovery = stage == "recovery_required"
|
||||||
|
if e := save(filepath.Join(m.jobDir(j.ID), "status.json"), j); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
f, e := os.OpenFile(filepath.Join(m.jobDir(j.ID), "events.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, e = fmt.Fprintf(f, "%d %s %s\n", j.UpdatedAt, stage, msg)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (m Manager) Start(ctx context.Context, version string, compose []byte) (*Job, string, error) {
|
||||||
|
if e := ValidateVersion(version); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
unlock, e := m.lock()
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
old, e := m.Status(ctx, "")
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if old != nil && (old.Active || old.RequiresRecovery) {
|
||||||
|
return old, "", errors.New("已有面板升级任务执行中或需要人工恢复")
|
||||||
|
}
|
||||||
|
p, e := m.Identity(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, e = rand.Read(b); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
id := hex.EncodeToString(b)
|
||||||
|
dir := m.jobDir(id)
|
||||||
|
if e = os.Mkdir(dir, 0700); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
j := Job{ID: id, TargetVersion: version, StartedAt: time.Now().UnixMilli(), HelperContainer: "flvx-upgrade-" + id, BackupDir: filepath.Join(p.HostDir, ".panel-upgrades", id)}
|
||||||
|
p.Job = j
|
||||||
|
if e = m.write(&j, "preparing", "正在准备独立升级任务"); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
if e = atomic(filepath.Join(m.root(), "latest"), []byte(id)); e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
fail := func(err error) (*Job, string, error) {
|
||||||
|
m.write(&j, "failed", "准备失败,线上配置未变更")
|
||||||
|
return &j, p.Image, err
|
||||||
|
}
|
||||||
|
for _, name := range []string{"docker-compose.yml", ".env"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(m.Dir, name))
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if err = atomic(filepath.Join(dir, name+".original"), data); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env, e := os.ReadFile(filepath.Join(dir, ".env.original"))
|
||||||
|
if e != nil {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(env), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "FLUX_VERSION=") || strings.HasPrefix(strings.TrimSpace(line), "export FLUX_VERSION=") {
|
||||||
|
lines[i] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env = []byte(strings.Join(lines, "\n") + "\nFLUX_VERSION=" + version + "\n")
|
||||||
|
if e = atomic(filepath.Join(dir, ".env"), env); e != nil {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
if e = atomic(filepath.Join(dir, "docker-compose.yml"), compose); e != nil {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
p.Job = j
|
||||||
|
if e = save(filepath.Join(dir, "plan.json"), p); e != nil {
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
// No --rm: an exited helper is evidence needed after a daemon/client timeout.
|
||||||
|
args := []string{"run", "-d", "--name", j.HelperContainer, "--label", "io.flvx.upgrade-job=" + id, "--mount", "type=bind,src=" + p.HostDir + ",dst=" + p.HostDir, "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", "--entrypoint", "/app/paneld", p.Image, "panel-upgrade-helper", filepath.Join(p.HostDir, ".panel-upgrades", id, "plan.json")}
|
||||||
|
if _, e = docker(ctx, args...); e != nil {
|
||||||
|
c, ie := inspect(ctx, j.HelperContainer)
|
||||||
|
if ie != nil || !c.State.Running {
|
||||||
|
return &j, p.Image, errors.New("helper 启动结果不确定;任务已保留,请查询升级状态,勿重复启动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &j, p.Image, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package panelupgrade
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fakeDocker(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
d := t.TempDir()
|
||||||
|
if e := os.WriteFile(filepath.Join(d, "docker"), []byte("#!/bin/sh\n"+body), 0700); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", d+":"+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
func fixture(t *testing.T) (Manager, Plan) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Manager{Dir: dir, Backend: "flux-panel-backend"}
|
||||||
|
id := strings.Repeat("a", 32)
|
||||||
|
os.MkdirAll(m.jobDir(id), 0700)
|
||||||
|
j := Job{ID: id, TargetVersion: "2.5.0", HelperContainer: "helper", StartedAt: time.Now().UnixMilli()}
|
||||||
|
m.write(&j, "preparing", "ready")
|
||||||
|
atomic(filepath.Join(m.root(), "latest"), []byte(id))
|
||||||
|
p := Plan{Job: j, HostDir: dir, DeployDir: "/opt/flvx-panel", Project: "original-project", Backend: m.Backend, Image: "sha256:old"}
|
||||||
|
save(filepath.Join(m.jobDir(id), "plan.json"), p)
|
||||||
|
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||||
|
atomic(filepath.Join(dir, n), []byte("original"))
|
||||||
|
atomic(filepath.Join(m.jobDir(id), n+".original"), []byte("original"))
|
||||||
|
atomic(filepath.Join(m.jobDir(id), n), []byte("target"))
|
||||||
|
}
|
||||||
|
return m, p
|
||||||
|
}
|
||||||
|
func TestVersionRejectsComposeAndPathInjection(t *testing.T) {
|
||||||
|
for _, v := range []string{"latest", "../v1", "1.2.3\nSECRET=x", "1.2.3${TOKEN}", "1.2.3 x", "1.2.3;id"} {
|
||||||
|
if ValidateVersion(v) == nil {
|
||||||
|
t.Fatal(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"2.5.0-rc10", "v2.5.0", "2.1.9-beta6"} {
|
||||||
|
if ValidateVersion(v) != nil {
|
||||||
|
t.Fatal(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPersistentLockAcrossManagers(t *testing.T) {
|
||||||
|
m, p := fixture(t)
|
||||||
|
fakeDocker(t, "exit 1\n")
|
||||||
|
_, _, e := m.Start(context.Background(), "2.5.0", nil)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("accepted concurrent job")
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(filepath.Join(m.root(), "latest"))
|
||||||
|
if string(b) != p.Job.ID {
|
||||||
|
t.Fatal("changed active job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUncertainHelperNeverUnlocks(t *testing.T) {
|
||||||
|
m, p := fixture(t)
|
||||||
|
j, _ := m.read(p.Job.ID)
|
||||||
|
j.UpdatedAt = 1
|
||||||
|
save(filepath.Join(m.jobDir(j.ID), "status.json"), j)
|
||||||
|
fakeDocker(t, "exit 1\n")
|
||||||
|
j, e := m.Status(context.Background(), "")
|
||||||
|
if e != nil || !j.Active || !j.RequiresRecovery || j.Stage != "recovery_required" {
|
||||||
|
t.Fatalf("%+v %v", j, e)
|
||||||
|
}
|
||||||
|
if _, _, e = m.Start(context.Background(), "2.5.0", nil); e == nil {
|
||||||
|
t.Fatal("uncertain launch unlocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func configs(p Plan) (string, string) {
|
||||||
|
old := composeConfig{}
|
||||||
|
json.Unmarshal([]byte(`{"services":{"backend":{"image":"old","container_name":"flux-panel-backend","environment":{"FLUX_VERSION":"old"}},"frontend":{"image":"old","container_name":"vite-frontend"}}}`), &old)
|
||||||
|
a, _ := json.Marshal(old)
|
||||||
|
next := old
|
||||||
|
next.Services = make(map[string]struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
Volumes []map[string]any `json:"volumes"`
|
||||||
|
Environment map[string]string `json:"environment"`
|
||||||
|
})
|
||||||
|
for k, v := range old.Services {
|
||||||
|
next.Services[k] = v
|
||||||
|
}
|
||||||
|
v := next.Services["backend"]
|
||||||
|
v.Image = "ghcr.io/sagit-chu/flux-panel-backend:" + p.Job.TargetVersion
|
||||||
|
v.Environment = map[string]string{"FLUX_VERSION": p.Job.TargetVersion}
|
||||||
|
next.Services["backend"] = v
|
||||||
|
v = next.Services["frontend"]
|
||||||
|
v.Image = "ghcr.io/sagit-chu/vite-frontend:" + p.Job.TargetVersion
|
||||||
|
next.Services["frontend"] = v
|
||||||
|
b, _ := json.Marshal(next)
|
||||||
|
return string(a), string(b)
|
||||||
|
}
|
||||||
|
func TestHelperFailureBoundaries(t *testing.T) {
|
||||||
|
for _, stage := range []string{"config", "pull", "apply", "up", "health", "success"} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
m, p := fixture(t)
|
||||||
|
old, next := configs(p)
|
||||||
|
dir := m.jobDir(p.Job.ID)
|
||||||
|
if stage == "apply" {
|
||||||
|
os.Remove(filepath.Join(dir, ".env"))
|
||||||
|
os.Mkdir(filepath.Join(dir, ".env"), 0700)
|
||||||
|
}
|
||||||
|
if stage == "health" {
|
||||||
|
before := verificationTimeout
|
||||||
|
verificationTimeout = 0
|
||||||
|
t.Cleanup(func() { verificationTimeout = before })
|
||||||
|
}
|
||||||
|
script := `case "$*" in
|
||||||
|
*"config --format json"*)
|
||||||
|
case "$*" in *"` + p.Job.ID + `"*) echo '` + next + `';; *) echo '` + old + `';; esac
|
||||||
|
;;
|
||||||
|
*"pull backend frontend"*) `
|
||||||
|
if stage == "pull" {
|
||||||
|
script += "echo SECRET >&2; exit 1"
|
||||||
|
} else {
|
||||||
|
script += "exit 0"
|
||||||
|
}
|
||||||
|
script += `;;
|
||||||
|
"image inspect"*) echo sha256:target;;
|
||||||
|
*"up -d"*) `
|
||||||
|
if stage == "up" {
|
||||||
|
script += "exit 1"
|
||||||
|
} else {
|
||||||
|
script += "touch \"" + filepath.Join(dir, "recreated") + "\"; exit 0"
|
||||||
|
}
|
||||||
|
script += `;;
|
||||||
|
"inspect flux-panel-backend") if [ -f "` + filepath.Join(dir, "recreated") + `" ]; then IMG=sha256:target; else IMG=sha256:old; fi; echo '[{"Image":"'"$IMG"'","State":{"Running":true,"Health":{"Status":"healthy"}},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}}}]';;
|
||||||
|
"inspect vite-frontend") echo '[{"Image":"sha256:target","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"frontend"}}}]';;
|
||||||
|
"exec vite-frontend wget"*) exit 0;;
|
||||||
|
*) exit 1;; esac
|
||||||
|
`
|
||||||
|
if stage == "config" {
|
||||||
|
script = "echo SECRET >&2;exit 1\n"
|
||||||
|
}
|
||||||
|
fakeDocker(t, script)
|
||||||
|
e := RunHelper(filepath.Join(dir, "plan.json"))
|
||||||
|
j, _ := m.read(p.Job.ID)
|
||||||
|
want := "failed"
|
||||||
|
if stage == "up" || stage == "health" {
|
||||||
|
want = "recovery_required"
|
||||||
|
}
|
||||||
|
if stage == "success" {
|
||||||
|
want = "succeeded"
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j.Stage != want {
|
||||||
|
t.Fatalf("stage=%s error=%v", j.Stage, e)
|
||||||
|
}
|
||||||
|
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||||
|
b, _ := os.ReadFile(filepath.Join(m.Dir, n))
|
||||||
|
expected := "original"
|
||||||
|
if stage == "up" || stage == "success" || stage == "health" {
|
||||||
|
expected = "target"
|
||||||
|
}
|
||||||
|
if string(b) != expected {
|
||||||
|
t.Fatalf("%s mutated unexpectedly", n)
|
||||||
|
}
|
||||||
|
b, _ = os.ReadFile(filepath.Join(dir, n+".original"))
|
||||||
|
if string(b) != "original" {
|
||||||
|
t.Fatal("backup overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(filepath.Join(dir, "events.log"))
|
||||||
|
if strings.Contains(string(b), "SECRET") {
|
||||||
|
t.Fatal("secret leaked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestComposeUsesOriginalHostPathAndProject(t *testing.T) {
|
||||||
|
p := Plan{Project: "my-project", HostDir: "/srv/panel"}
|
||||||
|
s := strings.Join(composeArgs(p, "/srv/panel/.panel-upgrades/job", "pull", "backend", "frontend"), " ")
|
||||||
|
if !strings.Contains(s, "--project-name my-project --project-directory /srv/panel") || strings.Contains(s, "remove-orphans") {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestStatusWithoutJobNeedsNoDocker(t *testing.T) {
|
||||||
|
fakeDocker(t, "exit 99\n")
|
||||||
|
j, e := (Manager{Dir: t.TempDir()}).Status(context.Background(), "")
|
||||||
|
if e != nil || j != nil {
|
||||||
|
t.Fatalf("%v %v", j, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullConfigRejectsUnintendedChanges(t *testing.T) {
|
||||||
|
for _, field := range []string{"ports", "network_mode", "command", "entrypoint", "privileged", "networks"} {
|
||||||
|
t.Run(field, func(t *testing.T) {
|
||||||
|
_, p := fixture(t)
|
||||||
|
oldJSON, nextJSON := configs(p)
|
||||||
|
var old, next composeConfig
|
||||||
|
json.Unmarshal([]byte(oldJSON), &old)
|
||||||
|
json.Unmarshal([]byte(nextJSON), &next)
|
||||||
|
var a, b struct {
|
||||||
|
Services map[string]map[string]any `json:"services"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(oldJSON), &a)
|
||||||
|
json.Unmarshal([]byte(nextJSON), &b)
|
||||||
|
old.FullServices = a.Services
|
||||||
|
next.FullServices = b.Services
|
||||||
|
old.FullServices["frontend"][field] = "private-custom-setting"
|
||||||
|
if validateConfig(old, next, p) == nil {
|
||||||
|
t.Fatal("accepted service setting removal: " + field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestLiveRejectsShadowMount(t *testing.T) {
|
||||||
|
_, p := fixture(t)
|
||||||
|
fakeDocker(t, `echo '[{"Image":"sha256:old","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}},"Mounts":[{"Type":"bind","Source":"/srv/db/gost.db","Destination":"/app/data/gost.db","RW":true}]}]'`)
|
||||||
|
oldJSON, _ := configs(p)
|
||||||
|
var old composeConfig
|
||||||
|
json.Unmarshal([]byte(oldJSON), &old)
|
||||||
|
if validateLive(context.Background(), p, old) == nil {
|
||||||
|
t.Fatal("accepted undeclared shadow mount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func identityJSON(dir, host, project, files string) string {
|
||||||
|
c := map[string]any{"Image": "sha256:old", "State": map[string]any{"Running": true}, "Config": map[string]any{"Labels": map[string]string{"com.docker.compose.project": project, "com.docker.compose.service": "backend", "com.docker.compose.project.working_dir": host, "com.docker.compose.project.config_files": files}}, "Mounts": []map[string]any{{"Type": "bind", "Source": host, "Destination": dir, "RW": true}}}
|
||||||
|
b, _ := json.Marshal([]any{c})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
func TestStartUsesHostMountAndKeepsUniqueBackups(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Manager{Dir: dir, Backend: "backend"}
|
||||||
|
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||||
|
os.WriteFile(filepath.Join(dir, n), []byte("original-secret"), 0600)
|
||||||
|
}
|
||||||
|
args := filepath.Join(t.TempDir(), "args")
|
||||||
|
fakeDocker(t, `case "$1" in inspect) echo '`+identityJSON(dir, "/srv/original-panel", "original-project", "/srv/original-panel/docker-compose.yml")+`';; run) printf '%s\n' "$@" > '`+args+`'; echo container-id;; *) exit 1;; esac`)
|
||||||
|
j, _, e := m.Start(context.Background(), "2.5.0-rc10", []byte("target"))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(args)
|
||||||
|
s := string(b)
|
||||||
|
for _, expected := range []string{"type=bind,src=/srv/original-panel,dst=/srv/original-panel", "/app/paneld", "sha256:old", "panel-upgrade-helper"} {
|
||||||
|
if !strings.Contains(s, expected) {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "--rm") || strings.Contains(s, "--volumes-from") {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
first := j.ID
|
||||||
|
m.write(j, "failed", "safe failure")
|
||||||
|
j, _, e = m.Start(context.Background(), "2.5.1", []byte("target2"))
|
||||||
|
if e != nil || j.ID == first {
|
||||||
|
t.Fatalf("%v %v", j, e)
|
||||||
|
}
|
||||||
|
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
|
||||||
|
b, _ := os.ReadFile(filepath.Join(dir, n))
|
||||||
|
if string(b) != "original-secret" {
|
||||||
|
t.Fatal("live or legacy overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ = os.ReadFile(filepath.Join(m.jobDir(first), ".env.original"))
|
||||||
|
if string(b) != "original-secret" {
|
||||||
|
t.Fatal("old job backup changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestIdentityFailsClosed(t *testing.T) {
|
||||||
|
for _, host := range []string{"/", "/app", "/usr", "/var/run", "/proc", "relative", "/srv/bad,host"} {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fakeDocker(t, "echo '"+identityJSON(dir, host, "project", filepath.Join(host, "docker-compose.yml"))+"'\n")
|
||||||
|
if _, e := (Manager{Dir: dir, Backend: "backend"}).Identity(context.Background()); e == nil {
|
||||||
|
t.Fatal(host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUncertainRunIsNotRetriedOrUnlocked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Manager{Dir: dir, Backend: "backend"}
|
||||||
|
for _, n := range []string{".env", "docker-compose.yml"} {
|
||||||
|
os.WriteFile(filepath.Join(dir, n), []byte("original"), 0600)
|
||||||
|
}
|
||||||
|
calls := filepath.Join(t.TempDir(), "runs")
|
||||||
|
fakeDocker(t, `case "$*" in "inspect backend") echo '`+identityJSON(dir, "/srv/panel", "project", "/srv/panel/docker-compose.yml")+`';; run*) echo run >> '`+calls+`'; exit 1;; *) exit 1;; esac`)
|
||||||
|
j, _, e := m.Start(context.Background(), "2.5.0", []byte("target"))
|
||||||
|
if e == nil || j == nil || !j.Active {
|
||||||
|
t.Fatalf("%v %v", j, e)
|
||||||
|
}
|
||||||
|
_, _, e = m.Start(context.Background(), "2.5.0", nil)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("retry accepted")
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(calls)
|
||||||
|
if string(b) != "run\n" {
|
||||||
|
t.Fatal("retried uncertain docker run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Package relaysecurity defines the versioned, endpoint-scoped relay contract.
|
||||||
|
// It authenticates relay clients, not the confidentiality of their transport.
|
||||||
|
package relaysecurity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Version = 1
|
||||||
|
const Required = "required"
|
||||||
|
const Legacy = "legacy"
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*Credentials, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, errors.New("relay credential randomness unavailable")
|
||||||
|
}
|
||||||
|
u := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(u); err != nil {
|
||||||
|
return nil, errors.New("relay credential randomness unavailable")
|
||||||
|
}
|
||||||
|
return &Credentials{Version: Version, Username: "relay-" + hex.EncodeToString(u), Password: hex.EncodeToString(b)}, nil
|
||||||
|
}
|
||||||
|
func (c *Credentials) Validate() error {
|
||||||
|
if c == nil || c.Version != Version || strings.TrimSpace(c.Username) == "" || len(c.Username) > 255 || len(c.Password) < 32 || len(c.Password) > 255 {
|
||||||
|
return errors.New("invalid required relay authentication")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *Credentials) Config() map[string]interface{} {
|
||||||
|
return map[string]interface{}{"username": c.Username, "password": c.Password}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import "database/sql"
|
|||||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||||
// word, so TableName() is required for correct quoting.
|
// word, so TableName() is required for correct quoting.
|
||||||
type User struct {
|
type User struct {
|
||||||
|
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||||
Pwd string `gorm:"type:varchar(100);not null"`
|
Pwd string `gorm:"type:varchar(100);not null"`
|
||||||
@@ -151,14 +152,15 @@ type NftCounterState struct {
|
|||||||
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
func (NftCounterState) TableName() string { return "nft_counter_state" }
|
||||||
|
|
||||||
type SpeedLimit struct {
|
type SpeedLimit struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
DisplayOrder int64 `gorm:"not null;default:0"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Speed int `gorm:"not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
Speed int `gorm:"not null"`
|
||||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||||
Status int `gorm:"not null"`
|
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||||
|
Status int `gorm:"not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||||
@@ -175,6 +177,7 @@ type StatisticsFlow struct {
|
|||||||
func (StatisticsFlow) TableName() string { return "statistics_flow" }
|
func (StatisticsFlow) TableName() string { return "statistics_flow" }
|
||||||
|
|
||||||
type Tunnel struct {
|
type Tunnel struct {
|
||||||
|
RelayAuthMode string `gorm:"column:relay_auth_mode;type:varchar(20);not null;default:legacy"`
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
|
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
|
||||||
@@ -354,24 +357,26 @@ type PeerShare struct {
|
|||||||
func (PeerShare) TableName() string { return "peer_share" }
|
func (PeerShare) TableName() string { return "peer_share" }
|
||||||
|
|
||||||
type PeerShareRuntime struct {
|
type PeerShareRuntime struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
RelayAuthVersion int `gorm:"column:relay_auth_version;not null;default:0" json:"-"`
|
||||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
RelayAuth string `gorm:"column:relay_auth;type:text;not null;default:''" json:"-"`
|
||||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||||
Role string `gorm:"type:text;not null;default:''"`
|
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
Role string `gorm:"type:text;not null;default:''"`
|
||||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||||
Port int `gorm:"not null;default:0"`
|
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||||
Target string `gorm:"type:text;not null;default:''"`
|
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||||
Applied int `gorm:"not null;default:0"`
|
Port int `gorm:"not null;default:0"`
|
||||||
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
|
Target string `gorm:"type:text;not null;default:''" json:"-"`
|
||||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
Applied int `gorm:"not null;default:0"`
|
||||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
|
||||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
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" }
|
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
|
||||||
@@ -414,6 +419,7 @@ type BackupData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserBackup struct {
|
type UserBackup struct {
|
||||||
|
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Pwd string `json:"pwd"`
|
Pwd string `json:"pwd"`
|
||||||
@@ -464,6 +470,7 @@ type NodeBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TunnelBackup struct {
|
type TunnelBackup struct {
|
||||||
|
RelayAuthMode string `json:"relayAuthMode,omitempty"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TrafficRatio float64 `json:"trafficRatio"`
|
TrafficRatio float64 `json:"trafficRatio"`
|
||||||
@@ -536,14 +543,15 @@ type UserTunnelBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SpeedLimitBackup struct {
|
type SpeedLimitBackup struct {
|
||||||
ID int64 `json:"id"`
|
DisplayOrder *int64 `json:"displayOrder,omitempty"`
|
||||||
Name string `json:"name"`
|
ID int64 `json:"id"`
|
||||||
Speed int64 `json:"speed"`
|
Name string `json:"name"`
|
||||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
Speed int64 `json:"speed"`
|
||||||
TunnelName string `json:"tunnelName,omitempty"`
|
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||||
CreatedTime int64 `json:"createdTime"`
|
TunnelName string `json:"tunnelName,omitempty"`
|
||||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
CreatedTime int64 `json:"createdTime"`
|
||||||
Status int `json:"status"`
|
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||||
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TunnelGroupBackup struct {
|
type TunnelGroupBackup struct {
|
||||||
@@ -621,6 +629,7 @@ type ForwardRecord struct {
|
|||||||
|
|
||||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||||
type TunnelRecord struct {
|
type TunnelRecord struct {
|
||||||
|
RelayAuthMode string
|
||||||
ID int64
|
ID int64
|
||||||
Type int
|
Type int
|
||||||
Status int
|
Status int
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// RelayEndpointAuth is private desired state, never an API or backup DTO.
|
||||||
|
// Retained endpoint identities keep their credentials across topology edits.
|
||||||
|
type RelayEndpointAuth struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"-"`
|
||||||
|
TunnelID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||||
|
NodeID int64 `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||||
|
ChainType int `gorm:"not null;uniqueIndex:idx_relay_endpoint" json:"-"`
|
||||||
|
Version int `gorm:"not null" json:"-"`
|
||||||
|
Username string `gorm:"type:text;not null" json:"-"`
|
||||||
|
Password string `gorm:"type:text;not null" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RelayEndpointAuth) TableName() string { return "relay_endpoint_auth" }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TunnelRuntimeCleanup retains ownership until an obsolete local runtime is
|
||||||
|
// acknowledged removed. It intentionally survives deletion of its tunnel.
|
||||||
|
type TunnelRuntimeCleanup struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
TunnelID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||||
|
NodeID int64 `gorm:"not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||||
|
Kind string `gorm:"type:varchar(16);not null;uniqueIndex:idx_tunnel_cleanup"`
|
||||||
|
Port int `gorm:"not null;default:0;uniqueIndex:idx_tunnel_cleanup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TunnelRuntimeCleanup) TableName() string { return "tunnel_runtime_cleanup" }
|
||||||
@@ -299,6 +299,8 @@ func autoMigrateAll(db *gorm.DB) error {
|
|||||||
&model.SpeedLimit{},
|
&model.SpeedLimit{},
|
||||||
&model.StatisticsFlow{},
|
&model.StatisticsFlow{},
|
||||||
&model.Tunnel{},
|
&model.Tunnel{},
|
||||||
|
&model.RelayEndpointAuth{},
|
||||||
|
&model.TunnelRuntimeCleanup{},
|
||||||
&model.ChainTunnel{},
|
&model.ChainTunnel{},
|
||||||
&model.UserTunnel{},
|
&model.UserTunnel{},
|
||||||
&model.TunnelGroup{},
|
&model.TunnelGroup{},
|
||||||
@@ -417,7 +419,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.HasTable(&model.Tunnel{}) {
|
if m.HasTable(&model.Tunnel{}) {
|
||||||
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort"} {
|
for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort", "RelayAuthMode"} {
|
||||||
if m.HasColumn(&model.Tunnel{}, field) {
|
if m.HasColumn(&model.Tunnel{}, field) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -912,7 +914,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
|
if err := r.db.Where("role_id != ?", 0).Order("display_order ASC, id DESC").Find(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userIDs := make([]int64, 0, len(users))
|
userIDs := make([]int64, 0, len(users))
|
||||||
@@ -952,7 +954,7 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var limits []model.SpeedLimit
|
var limits []model.SpeedLimit
|
||||||
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
|
if err := r.db.Order("display_order ASC, id DESC").Find(&limits).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]map[string]interface{}, 0, len(limits))
|
items := make([]map[string]interface{}, 0, len(limits))
|
||||||
@@ -999,7 +1001,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
var rows []fwdRow
|
var rows []fwdRow
|
||||||
err := r.db.Model(&model.Forward{}).
|
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, forward.proxy_protocol_receive, forward.proxy_protocol_send").
|
Select("forward.id, forward.user_id, COALESCE(owner.\"user\", forward.user_name) AS 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 "user" AS owner ON owner.id = forward.user_id`).
|
||||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
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").
|
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").
|
Order("forward.inx ASC, forward.id ASC").
|
||||||
@@ -1252,7 +1255,8 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
|
|||||||
tunnelMap[t.ID] = map[string]interface{}{
|
tunnelMap[t.ID] = map[string]interface{}{
|
||||||
"id": t.ID, "inx": t.Inx, "name": t.Name,
|
"id": t.ID, "inx": t.Inx, "name": t.Name,
|
||||||
"type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio,
|
"type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio,
|
||||||
"status": t.Status, "createdTime": t.CreatedTime,
|
"relayAuthMode": t.RelayAuthMode,
|
||||||
|
"status": t.Status, "createdTime": t.CreatedTime,
|
||||||
"inIp": nullableString(t.InIP),
|
"inIp": nullableString(t.InIP),
|
||||||
"ipPreference": t.IPPreference,
|
"ipPreference": t.IPPreference,
|
||||||
"probeTargetHost": t.ProbeTargetHost,
|
"probeTargetHost": t.ProbeTargetHost,
|
||||||
@@ -1616,8 +1620,10 @@ func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error
|
|||||||
return errors.New("runtime item is nil")
|
return errors.New("runtime item is nil")
|
||||||
}
|
}
|
||||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||||
"reservation_id": item.ReservationID,
|
"reservation_id": item.ReservationID,
|
||||||
"binding_id": item.BindingID, "role": item.Role,
|
"relay_auth": item.RelayAuth,
|
||||||
|
"relay_auth_version": item.RelayAuthVersion,
|
||||||
|
"binding_id": item.BindingID, "role": item.Role,
|
||||||
"chain_name": item.ChainName, "service_name": item.ServiceName,
|
"chain_name": item.ChainName, "service_name": item.ServiceName,
|
||||||
"protocol": item.Protocol, "strategy": item.Strategy,
|
"protocol": item.Protocol, "strategy": item.Strategy,
|
||||||
"port": item.Port, "target": item.Target,
|
"port": item.Port, "target": item.Target,
|
||||||
@@ -2087,7 +2093,8 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
|
|||||||
out := make([]model.UserBackup, 0, len(users))
|
out := make([]model.UserBackup, 0, len(users))
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
b := model.UserBackup{
|
b := model.UserBackup{
|
||||||
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
DisplayOrder: &u.DisplayOrder,
|
||||||
|
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
|
||||||
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, 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,
|
FlowResetTime: u.FlowResetTime, Num: u.Num,
|
||||||
CreatedTime: u.CreatedTime, Status: u.Status,
|
CreatedTime: u.CreatedTime, Status: u.Status,
|
||||||
@@ -2161,7 +2168,8 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
|
|||||||
out := make([]model.TunnelBackup, 0, len(tunnels))
|
out := make([]model.TunnelBackup, 0, len(tunnels))
|
||||||
for _, t := range tunnels {
|
for _, t := range tunnels {
|
||||||
b := model.TunnelBackup{
|
b := model.TunnelBackup{
|
||||||
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
|
RelayAuthMode: t.RelayAuthMode,
|
||||||
|
ID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,
|
||||||
Type: t.Type, Protocol: t.Protocol, Flow: t.Flow,
|
Type: t.Type, Protocol: t.Protocol, Flow: t.Flow,
|
||||||
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
|
||||||
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
|
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
|
||||||
@@ -2283,7 +2291,8 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
|
|||||||
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
out := make([]model.SpeedLimitBackup, 0, len(sls))
|
||||||
for _, sl := range sls {
|
for _, sl := range sls {
|
||||||
b := model.SpeedLimitBackup{
|
b := model.SpeedLimitBackup{
|
||||||
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
DisplayOrder: &sl.DisplayOrder,
|
||||||
|
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
|
||||||
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
CreatedTime: sl.CreatedTime, Status: sl.Status,
|
||||||
}
|
}
|
||||||
if sl.UpdatedTime.Valid {
|
if sl.UpdatedTime.Valid {
|
||||||
@@ -2366,7 +2375,24 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
|||||||
typeSet[t] = true
|
typeSet[t] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := backupImportTransaction(r.db, func(tx *gorm.DB) error {
|
||||||
|
result = &model.ImportResult{}
|
||||||
|
tables, err := lockPostgresBackupTables(tx, backup, typeSet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// SQLite still needs its writer reservation for display order.
|
||||||
|
// PostgreSQL membership locks were taken with all imported tables above.
|
||||||
|
if tx.Dialector.Name() != "postgres" && typeSet["users"] {
|
||||||
|
if err := lockDisplayOrder(tx, "user"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx.Dialector.Name() != "postgres" && typeSet["speedLimits"] {
|
||||||
|
if err := lockDisplayOrder(tx, "speed_limit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
now := unixMilliNow()
|
now := unixMilliNow()
|
||||||
|
|
||||||
if typeSet["users"] && len(backup.Users) > 0 {
|
if typeSet["users"] && len(backup.Users) > 0 {
|
||||||
@@ -2439,7 +2465,7 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
|||||||
}
|
}
|
||||||
result.ConfigsImported = count
|
result.ConfigsImported = count
|
||||||
}
|
}
|
||||||
return nil
|
return advancePostgresBackupSequences(tx, tables)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2471,12 +2497,17 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
|||||||
Status: status,
|
Status: status,
|
||||||
PasswordChangedAt: now,
|
PasswordChangedAt: now,
|
||||||
}
|
}
|
||||||
|
// An omitted old-backup rank preserves existing order on conflict.
|
||||||
|
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||||
|
columns := []string{"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
|
||||||
|
"flow_reset_time", "num", "updated_time", "status", "password_changed_at"}
|
||||||
|
if u.DisplayOrder != nil {
|
||||||
|
item.DisplayOrder = *u.DisplayOrder
|
||||||
|
columns = append(columns, "display_order")
|
||||||
|
}
|
||||||
err = tx.Clauses(clause.OnConflict{
|
err = tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns(columns),
|
||||||
"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
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
@@ -2595,6 +2626,18 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
|||||||
func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {
|
func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {
|
||||||
count := 0
|
count := 0
|
||||||
for _, t := range tunnels {
|
for _, t := range tunnels {
|
||||||
|
if t.RelayAuthMode != "" && t.RelayAuthMode != "legacy" {
|
||||||
|
return count, errors.New("authenticated relay tunnels require a full database restore; configuration backup has no private relay credentials")
|
||||||
|
}
|
||||||
|
var existing model.Tunnel
|
||||||
|
lookupErr := tx.First(&existing, t.ID).Error
|
||||||
|
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
||||||
|
return count, lookupErr
|
||||||
|
}
|
||||||
|
if lookupErr == nil && existing.RelayAuthMode == "required" {
|
||||||
|
return count, errors.New("configuration import cannot replace an authenticated relay tunnel")
|
||||||
|
}
|
||||||
|
|
||||||
item := model.Tunnel{
|
item := model.Tunnel{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
@@ -2747,11 +2790,16 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
|
|||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: sl.Status,
|
Status: sl.Status,
|
||||||
}
|
}
|
||||||
|
// An omitted old-backup rank preserves existing order on conflict.
|
||||||
|
// New legacy records use rank zero and ID descending, matching upgrades.
|
||||||
|
columns := []string{"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status"}
|
||||||
|
if sl.DisplayOrder != nil {
|
||||||
|
item.DisplayOrder = *sl.DisplayOrder
|
||||||
|
columns = append(columns, "display_order")
|
||||||
|
}
|
||||||
err := tx.Clauses(clause.OnConflict{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns(columns),
|
||||||
"name", "speed", "tunnel_id", "tunnel_name", "updated_time", "status",
|
|
||||||
}),
|
|
||||||
}).Create(&item).Error
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errBackupImportBusy = errors.New("database is busy; retry the backup import")
|
||||||
|
|
||||||
|
// Only retry lock acquisition failures, which occur before any backup writes.
|
||||||
|
// NOWAIT releases partial lock sets instead of deadlocking with existing
|
||||||
|
// child-before-parent deletes or forward-before-user traffic updates.
|
||||||
|
func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error {
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 10; attempt++ {
|
||||||
|
err = db.Transaction(importRows)
|
||||||
|
if !errors.Is(err, errBackupImportBusy) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if attempt < 9 {
|
||||||
|
time.Sleep(min(10*time.Millisecond<<attempt, 100*time.Millisecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock every table that import can write, including generated-ID child tables.
|
||||||
|
// No writes happen until the complete set is held. Existing CRUD transactions
|
||||||
|
// can therefore finish regardless of their own parent/child mutation order.
|
||||||
|
// The returned subset contains only tables receiving explicit backup IDs.
|
||||||
|
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
|
||||||
|
if tx.Dialector.Name() != "postgres" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
chains, grants, ports := 0, 0, 0
|
||||||
|
for _, tunnel := range backup.Tunnels {
|
||||||
|
chains += len(tunnel.ChainTunnels)
|
||||||
|
}
|
||||||
|
for _, permission := range backup.Permissions {
|
||||||
|
grants += len(permission.Grants)
|
||||||
|
}
|
||||||
|
for _, forward := range backup.Forwards {
|
||||||
|
if forward.ForwardPorts != nil {
|
||||||
|
ports++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tables []string
|
||||||
|
for _, item := range []struct {
|
||||||
|
kind string
|
||||||
|
count int
|
||||||
|
name string
|
||||||
|
explicitIDs bool
|
||||||
|
}{
|
||||||
|
{"users", len(backup.Users), "user", true},
|
||||||
|
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
|
||||||
|
{"nodes", len(backup.Nodes), "node", true},
|
||||||
|
{"tunnels", len(backup.Tunnels), "tunnel", true},
|
||||||
|
{"tunnels", chains, "chain_tunnel", true},
|
||||||
|
{"forwards", len(backup.Forwards), "forward", true},
|
||||||
|
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
|
||||||
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
|
||||||
|
{"userGroups", len(backup.UserGroups), "user_group", true},
|
||||||
|
{"permissions", len(backup.Permissions), "group_permission", true},
|
||||||
|
{"permissions", grants, "group_permission_grant", true},
|
||||||
|
{"users", len(backup.Users), "user_quota", false},
|
||||||
|
{"forwards", ports, "forward_port", false},
|
||||||
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
|
||||||
|
{"userGroups", len(backup.UserGroups), "user_group_user", false},
|
||||||
|
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
|
||||||
|
} {
|
||||||
|
if !types[item.kind] || item.count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Resolve the same relation as normal unqualified repository queries,
|
||||||
|
// even when the first search_path schema has no copy of this table.
|
||||||
|
var relation struct {
|
||||||
|
Schema string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
|
||||||
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if relation.Name == "" {
|
||||||
|
return nil, fmt.Errorf("missing import table %s", item.name)
|
||||||
|
}
|
||||||
|
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
|
||||||
|
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
|
||||||
|
var pgError interface{ SQLState() string }
|
||||||
|
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
|
||||||
|
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if item.explicitIDs {
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
|
||||||
|
for _, table := range tables {
|
||||||
|
if err := advancePostgresBackupSequence(tx, table); err != nil {
|
||||||
|
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
|
||||||
|
// startup's setval repair, RESTART is transactional and invalidates other
|
||||||
|
// connections' cached values. Never return to IDs already allocated, even if
|
||||||
|
// their rows were deleted or their transactions rolled back.
|
||||||
|
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
|
||||||
|
var maxID int64
|
||||||
|
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if maxID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type sequenceInfo struct {
|
||||||
|
Schema string
|
||||||
|
Name string
|
||||||
|
Cache int64
|
||||||
|
Increment int64
|
||||||
|
Maximum int64
|
||||||
|
}
|
||||||
|
var sequences []sequenceInfo
|
||||||
|
// Prefer the actual default's catalog dependency: a legacy custom nextval
|
||||||
|
// default need not own its sequence, and the old owned serial may remain.
|
||||||
|
// Identity columns have no pg_attrdef, so use their owned sequence instead.
|
||||||
|
if err := tx.Raw(`WITH defaults AS (
|
||||||
|
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
|
||||||
|
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
|
||||||
|
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
|
||||||
|
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
|
||||||
|
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
|
||||||
|
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
|
||||||
|
), candidates AS (
|
||||||
|
SELECT oid FROM defaults
|
||||||
|
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
|
||||||
|
)
|
||||||
|
SELECT n.nspname AS schema, c.relname AS name,
|
||||||
|
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
|
||||||
|
FROM pg_catalog.pg_sequence s
|
||||||
|
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(sequences) != 1 || sequences[0].Increment <= 0 {
|
||||||
|
return fmt.Errorf("missing or ambiguous ascending ID sequence")
|
||||||
|
}
|
||||||
|
sequence := sequences[0]
|
||||||
|
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
|
||||||
|
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
|
||||||
|
// Read last_value only after that lock: even direct concurrent nextval calls
|
||||||
|
// must finish first. The table locks already exclude normal INSERTs.
|
||||||
|
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var state struct {
|
||||||
|
LastValue int64
|
||||||
|
IsCalled bool
|
||||||
|
}
|
||||||
|
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if state.LastValue < 1 {
|
||||||
|
return fmt.Errorf("ID sequence must allocate positive IDs")
|
||||||
|
}
|
||||||
|
if maxID >= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) {
|
||||||
|
return fmt.Errorf("ID sequence exhausted")
|
||||||
|
}
|
||||||
|
next := state.LastValue
|
||||||
|
if state.IsCalled {
|
||||||
|
next += sequence.Increment
|
||||||
|
}
|
||||||
|
if next <= maxID {
|
||||||
|
steps := (maxID-next)/sequence.Increment + 1
|
||||||
|
if steps > (sequence.Maximum-next)/sequence.Increment {
|
||||||
|
return fmt.Errorf("ID sequence exhausted")
|
||||||
|
}
|
||||||
|
next += steps * sequence.Increment
|
||||||
|
}
|
||||||
|
return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error
|
||||||
|
}
|
||||||
@@ -37,8 +37,14 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
|||||||
if tx == nil {
|
if tx == nil {
|
||||||
return nil, errors.New("database unavailable")
|
return nil, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
var forwards []model.Forward
|
var forwards []struct {
|
||||||
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
model.Forward
|
||||||
|
CurrentUserName string
|
||||||
|
}
|
||||||
|
err := tx.Model(&model.Forward{}).
|
||||||
|
Select(`forward.*, COALESCE(owner."user", forward.user_name) AS current_user_name`).
|
||||||
|
Joins(`LEFT JOIN "user" AS owner ON owner.id = forward.user_id`).
|
||||||
|
Where("forward.tunnel_id = ?", tunnelID).Order("forward.id ASC").Find(&forwards).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -48,7 +54,7 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
|||||||
rows = append(rows, model.ForwardRecord{
|
rows = append(rows, model.ForwardRecord{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
UserID: f.UserID,
|
UserID: f.UserID,
|
||||||
UserName: f.UserName,
|
UserName: f.CurrentUserName,
|
||||||
Name: f.Name,
|
Name: f.Name,
|
||||||
TunnelID: f.TunnelID,
|
TunnelID: f.TunnelID,
|
||||||
RemoteAddr: f.RemoteAddr,
|
RemoteAddr: f.RemoteAddr,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDisplayOrderConflict = errors.New("列表已变更,请刷新后重试")
|
||||||
|
var ErrInvalidDisplayOrder = errors.New("排序必须包含全部条目且不能重复")
|
||||||
|
|
||||||
|
// Lock the entire membership, including an empty list and concurrent INSERTs.
|
||||||
|
// PostgreSQL row locks alone cannot protect the missing-row/phantom case.
|
||||||
|
// SQLite's first write acquires its writer reservation before reading the list.
|
||||||
|
func lockDisplayOrder(tx *gorm.DB, table string) error {
|
||||||
|
if tx.Dialector.Name() == "postgres" {
|
||||||
|
return tx.Exec("LOCK TABLE \"" + table + "\" IN SHARE ROW EXCLUSIVE MODE").Error
|
||||||
|
}
|
||||||
|
return tx.Table(table).Where("1 = 0").UpdateColumn("display_order", gorm.Expr("display_order")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateUserDisplayOrder(ids, expected []int64) error {
|
||||||
|
return r.updateDisplayOrder("user", ids, expected)
|
||||||
|
}
|
||||||
|
func (r *Repository) UpdateSpeedLimitDisplayOrder(ids, expected []int64) error {
|
||||||
|
return r.updateDisplayOrder("speed_limit", ids, expected)
|
||||||
|
}
|
||||||
|
func (r *Repository) updateDisplayOrder(table string, ids, expected []int64) error {
|
||||||
|
if ids == nil || expected == nil {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || seen[id] {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
seenExpected := map[int64]bool{}
|
||||||
|
for _, id := range expected {
|
||||||
|
if id <= 0 || seenExpected[id] {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
seenExpected[id] = true
|
||||||
|
}
|
||||||
|
if len(ids) != len(expected) {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
for id := range seen {
|
||||||
|
if !seenExpected[id] {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockDisplayOrder(tx, table); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := tx.Table(table)
|
||||||
|
if table == "user" {
|
||||||
|
var admins int64
|
||||||
|
if err := tx.Table(table).Where("role_id = ? AND id IN ?", 0, ids).Count(&admins).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if admins != 0 {
|
||||||
|
return ErrInvalidDisplayOrder
|
||||||
|
}
|
||||||
|
q = q.Where("role_id != ?", 0)
|
||||||
|
}
|
||||||
|
var current []int64
|
||||||
|
if err := q.Order("display_order ASC, id DESC").Pluck("id", ¤t).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Equal(current, expected) {
|
||||||
|
return ErrDisplayOrderConflict
|
||||||
|
}
|
||||||
|
for i, id := range ids {
|
||||||
|
result := tx.Table(table).Where("id = ?", id).UpdateColumn("display_order", int64(i))
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected != 1 {
|
||||||
|
return ErrDisplayOrderConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// New items precede all saved ranks. Zero is only the legacy migration default.
|
||||||
|
func createFirst(tx *gorm.DB, table string, item interface{}, rank *int64) error {
|
||||||
|
return tx.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := lockDisplayOrder(tx, table); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := tx.Table(table)
|
||||||
|
if table == "user" {
|
||||||
|
q = q.Where("role_id != ?", 0)
|
||||||
|
}
|
||||||
|
var first int64
|
||||||
|
if err := q.Select("COALESCE(MIN(display_order), 0)").Scan(&first).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if first == -1<<63 {
|
||||||
|
return errors.New("display order exhausted")
|
||||||
|
}
|
||||||
|
*rank = first - 1
|
||||||
|
return tx.Create(item).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -202,6 +202,13 @@ func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, port := range pendingPorts {
|
||||||
|
used[port] = struct{}{}
|
||||||
|
}
|
||||||
result := make([]int, 0, len(used))
|
result := make([]int, 0, len(used))
|
||||||
for p := range used {
|
for p := range used {
|
||||||
result = append(result, p)
|
result = append(result, p)
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tr := model.TunnelRecord{
|
tr := model.TunnelRecord{
|
||||||
|
RelayAuthMode: t.RelayAuthMode,
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Type: t.Type,
|
Type: t.Type,
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
|||||||
Status: status,
|
Status: status,
|
||||||
PasswordChangedAt: now,
|
PasswordChangedAt: now,
|
||||||
}
|
}
|
||||||
if err := r.db.Create(&user).Error; err != nil {
|
if err := createFirst(r.db, "user", &user, &user.DisplayOrder); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return user.ID, nil
|
return user.ID, nil
|
||||||
@@ -151,6 +151,13 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) DisableUserTunnels(userID int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
return r.db.Model(&model.UserTunnel{}).Where("user_id = ?", userID).Update("status", 0).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) DeleteUserCascade(userID int64) error {
|
func (r *Repository) DeleteUserCascade(userID int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
@@ -577,6 +584,13 @@ func (r *Repository) pickNodePortTx(tx *gorm.DB, nodeID int64, allocated map[int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingPorts, err := r.PendingTunnelCleanupPortsTx(tx, nodeID, excludeTunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, port := range pendingPorts {
|
||||||
|
used[port] = struct{}{}
|
||||||
|
}
|
||||||
var available []int
|
var available []int
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if candidate <= 0 {
|
if candidate <= 0 {
|
||||||
@@ -622,6 +636,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
|||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := r.queueDeletedTunnelRuntimeTx(tx, tunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("tunnel_id = ?", tunnelID)
|
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("tunnel_id = ?", tunnelID)
|
||||||
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
|
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -638,6 +655,9 @@ func (r *Repository) DeleteTunnelCascade(tunnelID int64) error {
|
|||||||
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.FederationTunnelBinding{}).Error; err != nil {
|
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.FederationTunnelBinding{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := tx.Where("tunnel_id = ?", tunnelID).Delete(&model.RelayEndpointAuth{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Where("id = ?", tunnelID).Delete(&model.Tunnel{}).Error
|
return tx.Where("id = ?", tunnelID).Delete(&model.Tunnel{}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -897,6 +917,13 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
|
|||||||
for _, p := range chainPorts {
|
for _, p := range chainPorts {
|
||||||
used[p] = true
|
used[p] = true
|
||||||
}
|
}
|
||||||
|
pendingPorts, err := r.PendingTunnelCleanupPortsTx(nil, nodeID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, port := range pendingPorts {
|
||||||
|
used[port] = true
|
||||||
|
}
|
||||||
return used, nil
|
return used, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +940,7 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status
|
|||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
if err := r.db.Create(&sl).Error; err != nil {
|
if err := createFirst(r.db, "speed_limit", &sl, &sl.DisplayOrder); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return sl.ID, nil
|
return sl.ID, nil
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error)
|
|||||||
return &key, err
|
return &key, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credential IDs are unique across all users. Resolve the owner from the stored
|
||||||
|
// credential before checking the untrusted user handle in a discoverable login.
|
||||||
|
func (r *Repository) GetPasskeyByID(id string) (*model.Passkey, error) {
|
||||||
|
var key model.Passkey
|
||||||
|
err := r.db.Where("id = ?", id).Take(&key).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &key, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) CreatePasskey(key *model.Passkey) error {
|
func (r *Repository) CreatePasskey(key *model.Passkey) error {
|
||||||
return r.db.Create(key).Error
|
return r.db.Create(key).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelayEndpointCredentials loads existing state, creating only when explicitly
|
||||||
|
// requested by a new endpoint operation. Reconnects must never recreate a secret.
|
||||||
|
func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int64, role int, create bool) (*relaysecurity.Credentials, error) {
|
||||||
|
db := tx
|
||||||
|
if db == nil {
|
||||||
|
db = r.db
|
||||||
|
}
|
||||||
|
var row model.RelayEndpointAuth
|
||||||
|
find := func() error {
|
||||||
|
return db.Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).First(&row).Error
|
||||||
|
}
|
||||||
|
err := find()
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) && create {
|
||||||
|
var existing int64
|
||||||
|
if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).Count(&existing).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
var tunnel model.Tunnel
|
||||||
|
if e := db.First(&tunnel, tunnelID).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if tunnel.Type == 2 || tunnel.RelayAuthMode == relaysecurity.Required {
|
||||||
|
return nil, errors.New("required relay endpoint credentials unavailable; refusing regeneration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c, e := relaysecurity.New()
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
row = model.RelayEndpointAuth{TunnelID: tunnelID, NodeID: nodeID, ChainType: role, Version: c.Version, Username: c.Username, Password: c.Password}
|
||||||
|
if e = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
row = model.RelayEndpointAuth{}
|
||||||
|
err = find()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("required relay endpoint credentials unavailable")
|
||||||
|
}
|
||||||
|
c := &relaysecurity.Credentials{Version: row.Version, Username: row.Username, Password: row.Password}
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SetTunnelRelayAuthModeTx(tx *gorm.DB, tunnelID int64, mode string) error {
|
||||||
|
if mode != relaysecurity.Required && mode != relaysecurity.Legacy {
|
||||||
|
return errors.New("invalid relay authentication mode")
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Update("relay_auth_mode", mode).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gsqlite "github.com/glebarez/sqlite"
|
||||||
|
"go-backend/internal/relaysecurity"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenBackfillsSQLiteTunnelRelayAuthMode(t *testing.T) {
|
||||||
|
for _, version := range []int{0, currentSchemaVersion} {
|
||||||
|
t.Run(fmt.Sprintf("schema_version_%d", version), func(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "legacy-relay.db")
|
||||||
|
db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open legacy sqlite: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get legacy sqlite connection: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
// This is the tunnel schema immediately before relay authentication.
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE tunnel (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||||
|
flow INTEGER NOT NULL,
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
in_ip TEXT,
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ip_preference VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
probe_target_host TEXT NOT NULL DEFAULT '',
|
||||||
|
probe_target_port INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy tunnel table: %v", err)
|
||||||
|
}
|
||||||
|
legacy := model.Tunnel{
|
||||||
|
ID: 41, Name: "existing-relay", TrafficRatio: 1.5,
|
||||||
|
Type: 2, Protocol: "tcp", Flow: 123,
|
||||||
|
CreatedTime: 111, UpdatedTime: 222, Status: 1,
|
||||||
|
InIP: sql.NullString{String: "192.0.2.1", Valid: true},
|
||||||
|
Inx: 3, IPPreference: "ipv4",
|
||||||
|
ProbeTargetHost: "example.com", ProbeTargetPort: 8443,
|
||||||
|
}
|
||||||
|
if err := db.Omit("RelayAuthMode").Create(&legacy).Error; err != nil {
|
||||||
|
t.Fatalf("seed legacy tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.SchemaVersion{}); err != nil {
|
||||||
|
t.Fatalf("create schema version table: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&model.SchemaVersion{Version: version}).Error; err != nil {
|
||||||
|
t.Fatalf("seed schema version: %v", err)
|
||||||
|
}
|
||||||
|
if err := sqlDB.Close(); err != nil {
|
||||||
|
t.Fatalf("close legacy sqlite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open migrated sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
|
||||||
|
t.Fatal("expected tunnel.relay_auth_mode after upgrading an existing database")
|
||||||
|
}
|
||||||
|
legacy.RelayAuthMode = relaysecurity.Legacy
|
||||||
|
assertSQLiteRelayTunnel(t, r, legacy)
|
||||||
|
|
||||||
|
var createdID int64
|
||||||
|
if err := r.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
createdID, err = r.CreateTunnelTx(tx, "after-upgrade", 1, 1, 1, 333, 1, nil, 0, "", "", 0)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create tunnel after upgrade: %v", err)
|
||||||
|
}
|
||||||
|
if createdID <= legacy.ID {
|
||||||
|
t.Fatalf("expected new tunnel ID above %d, got %d", legacy.ID, createdID)
|
||||||
|
}
|
||||||
|
var created model.Tunnel
|
||||||
|
if err := r.DB().First(&created, createdID).Error; err != nil {
|
||||||
|
t.Fatalf("read newly created tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if created.RelayAuthMode != relaysecurity.Legacy {
|
||||||
|
t.Fatalf("expected legacy default for created tunnel, got %q", created.RelayAuthMode)
|
||||||
|
}
|
||||||
|
if err := r.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
return r.SetTunnelRelayAuthModeTx(tx, createdID, relaysecurity.Required)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("enable relay authentication: %v", err)
|
||||||
|
}
|
||||||
|
created.RelayAuthMode = relaysecurity.Required
|
||||||
|
|
||||||
|
// Startup repair must be idempotent and never downgrade required mode.
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatalf("close migrated sqlite: %v", err)
|
||||||
|
}
|
||||||
|
r, err = Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen migrated sqlite: %v", err)
|
||||||
|
}
|
||||||
|
assertSQLiteRelayTunnel(t, r, legacy)
|
||||||
|
assertSQLiteRelayTunnel(t, r, created)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := r.DB().Model(&model.Tunnel{}).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count migrated tunnels: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("expected two tunnels after repeated startup, got %d", count)
|
||||||
|
}
|
||||||
|
var integrity string
|
||||||
|
if err := r.DB().Raw("PRAGMA integrity_check").Scan(&integrity).Error; err != nil {
|
||||||
|
t.Fatalf("check migrated database integrity: %v", err)
|
||||||
|
}
|
||||||
|
if integrity != "ok" {
|
||||||
|
t.Fatalf("unexpected database integrity result: %s", integrity)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenNewSQLiteTunnelRelayAuthMode(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "new-relay.db")
|
||||||
|
r, err := Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open new sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
if !r.DB().Migrator().HasColumn(&model.Tunnel{}, "RelayAuthMode") {
|
||||||
|
t.Fatal("expected tunnel.relay_auth_mode on a fresh database")
|
||||||
|
}
|
||||||
|
// Omit the field explicitly to test SQLite's default, not GORM's default.
|
||||||
|
tunnel := model.Tunnel{
|
||||||
|
Name: "new-tunnel", TrafficRatio: 1, Type: 1, Protocol: "tls",
|
||||||
|
Flow: 1, CreatedTime: 111, UpdatedTime: 111, Status: 1,
|
||||||
|
}
|
||||||
|
if err := r.DB().Omit("RelayAuthMode").Create(&tunnel).Error; err != nil {
|
||||||
|
t.Fatalf("create tunnel without explicit relay mode: %v", err)
|
||||||
|
}
|
||||||
|
tunnel.RelayAuthMode = relaysecurity.Legacy
|
||||||
|
assertSQLiteRelayTunnel(t, r, tunnel)
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatalf("close new sqlite: %v", err)
|
||||||
|
}
|
||||||
|
r, err = Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen new sqlite: %v", err)
|
||||||
|
}
|
||||||
|
assertSQLiteRelayTunnel(t, r, tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSQLiteRelayTunnel(t *testing.T, r *Repository, want model.Tunnel) {
|
||||||
|
t.Helper()
|
||||||
|
var got model.Tunnel
|
||||||
|
if err := r.DB().First(&got, want.ID).Error; err != nil {
|
||||||
|
t.Fatalf("read tunnel %d: %v", want.ID, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("tunnel data changed: got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repository) QueueTunnelRuntimeCleanupTx(tx *gorm.DB, items []model.TunnelRuntimeCleanup) error {
|
||||||
|
for _, item := range items {
|
||||||
|
var node model.Node
|
||||||
|
if err := tx.Select("id", "is_remote").Where("id = ?", item.NodeID).First(&node).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "kind"}, {Name: "port"}}, DoNothing: true}).Create(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *Repository) ListTunnelRuntimeCleanup(tunnelID int64) ([]model.TunnelRuntimeCleanup, error) {
|
||||||
|
var items []model.TunnelRuntimeCleanup
|
||||||
|
q := r.db.Model(&model.TunnelRuntimeCleanup{})
|
||||||
|
if tunnelID > 0 {
|
||||||
|
q = q.Where("tunnel_id = ?", tunnelID)
|
||||||
|
}
|
||||||
|
err := q.Order("tunnel_id, node_id, kind DESC").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
func (r *Repository) DeleteTunnelRuntimeCleanup(id int64) error {
|
||||||
|
return r.db.Where("id = ?", id).Delete(&model.TunnelRuntimeCleanup{}).Error
|
||||||
|
}
|
||||||
|
func (r *Repository) PendingTunnelCleanupPortsTx(tx *gorm.DB, nodeID, excludeTunnelID int64) ([]int, error) {
|
||||||
|
if tx == nil {
|
||||||
|
tx = r.db
|
||||||
|
}
|
||||||
|
q := tx.Model(&model.TunnelRuntimeCleanup{}).Where("node_id = ? AND kind = ? AND port > 0", nodeID, "service")
|
||||||
|
if excludeTunnelID > 0 {
|
||||||
|
q = q.Where("tunnel_id != ?", excludeTunnelID)
|
||||||
|
}
|
||||||
|
var ports []int
|
||||||
|
err := q.Pluck("port", &ports).Error
|
||||||
|
return ports, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queueDeletedTunnelRuntimeTx(tx *gorm.DB, tunnelID int64) error {
|
||||||
|
var tunnels []model.Tunnel
|
||||||
|
if err := tx.Where("id = ? AND type = ?", tunnelID, 2).Find(&tunnels).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(tunnels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []model.ChainTunnel
|
||||||
|
if err := tx.Where("tunnel_id = ?", tunnelID).Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var items []model.TunnelRuntimeCleanup
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ChainType == "2" || row.ChainType == "3" {
|
||||||
|
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "service", Port: int(row.Port.Int64)})
|
||||||
|
}
|
||||||
|
if row.ChainType == "1" || row.ChainType == "2" {
|
||||||
|
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: row.NodeID, Kind: "chain"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.QueueTunnelRuntimeCleanupTx(tx, items)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func orderRequest(t *testing.T, router http.Handler, token, resource, body string) response.R {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/"+resource+"/update-order", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v (%s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func TestDisplayOrderUserContract(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "order-secret")
|
||||||
|
token := mustAdminToken(t, "order-secret")
|
||||||
|
a, err := r.CreateUser("order-a", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := r.CreateUser("order-b", "hash", 1, 0, 20, 1, 2, 1, 3, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := orderRequest(t, router, token, "user", fmt.Sprintf(`{"ids":[%d,%d],"expectedIds":[%d,%d]}`, a, b, b, a))
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("reorder: %+v", out)
|
||||||
|
}
|
||||||
|
users, err := r.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := []int64{users[0]["id"].(int64), users[1]["id"].(int64)}; !reflect.DeepEqual(got, []int64{a, b}) {
|
||||||
|
t.Fatalf("order %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayOrderValidationAndLifecycle(t *testing.T) {
|
||||||
|
for _, resource := range []string{"user", "speed-limit"} {
|
||||||
|
t.Run(resource, func(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "order-secret")
|
||||||
|
token := mustAdminToken(t, "order-secret")
|
||||||
|
create := func(name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
var err error
|
||||||
|
if resource == "user" {
|
||||||
|
id, err = r.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||||
|
} else {
|
||||||
|
id, err = r.CreateSpeedLimit(name, 100, 0, 1)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
list := func() []int64 {
|
||||||
|
t.Helper()
|
||||||
|
out := postBatchRequest(t, router, token, "/api/v1/"+resource+"/list", "{}")
|
||||||
|
rows, ok := out.Data.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("list data: %T", out.Data)
|
||||||
|
}
|
||||||
|
ids := []int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, int64(row.(map[string]interface{})["id"].(float64)))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
a, b := create("a"), create("b")
|
||||||
|
body := func(ids, expected []int64) string {
|
||||||
|
v, _ := json.Marshal(map[string]interface{}{"ids": ids, "expectedIds": expected})
|
||||||
|
return string(v)
|
||||||
|
}
|
||||||
|
check := func(payload string, code int) {
|
||||||
|
t.Helper()
|
||||||
|
out := orderRequest(t, router, token, resource, payload)
|
||||||
|
if out.Code != code {
|
||||||
|
t.Fatalf("want %d got %+v", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(body([]int64{a, b}, []int64{b, a}), 0)
|
||||||
|
for _, payload := range []string{`{}`, `{"ids":null,"expectedIds":[]}`, `{"ids":[1.1],"expectedIds":[1]}`, body([]int64{a, a}, []int64{a, b}), body([]int64{a}, []int64{a, b}), body([]int64{a, b, 99999}, []int64{a, b}), body([]int64{a, 99999}, []int64{a, b}), body([]int64{a, b}, []int64{a, a})} {
|
||||||
|
check(payload, 400)
|
||||||
|
}
|
||||||
|
if resource == "user" {
|
||||||
|
check(body([]int64{1, a, b}, []int64{a, b}), 400)
|
||||||
|
check(body([]int64{1, a, b}, []int64{1, a, b}), 400)
|
||||||
|
}
|
||||||
|
check(body([]int64{b, a}, []int64{b, a}), 409)
|
||||||
|
c := create("c")
|
||||||
|
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||||
|
t.Fatalf("new item first: %v", got)
|
||||||
|
}
|
||||||
|
check(body([]int64{b, a}, []int64{a, b}), 409)
|
||||||
|
if resource == "user" {
|
||||||
|
if err := r.UpdateUserWithoutPassword(a, "edited", 10, 2, 0, 1, 1, 3, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := r.UpdateSpeedLimit(a, "edited", 200, 1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := list(); !reflect.DeepEqual(got, []int64{c, a, b}) {
|
||||||
|
t.Fatalf("edit changed order: %v", got)
|
||||||
|
}
|
||||||
|
if got := orderRequest(t, router, "", resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 401 {
|
||||||
|
t.Fatalf("missing auth: %+v", got)
|
||||||
|
}
|
||||||
|
normal, err := auth.GenerateToken(a, "edited", 1, "order-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Speed-limit fixture needs a real ordinary account for auth lookup.
|
||||||
|
if resource == "speed-limit" {
|
||||||
|
uid, err := r.CreateUser("normal", "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
normal, err = auth.GenerateToken(uid, "normal", 1, "order-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := orderRequest(t, router, normal, resource, body([]int64{b, a, c}, []int64{c, a, b})); got.Code != 403 {
|
||||||
|
t.Fatalf("non-admin auth: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayOrderBackupAndRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "order.db")
|
||||||
|
r, err := repo.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := r.CreateSpeedLimit("a", 100, 0, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := r.CreateSpeedLimit("b", 200, 0, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check := func(r *repo.Repository) {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := r.ListSpeedLimits()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := []int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, row["id"].(int64))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(ids, []int64{a, b}) {
|
||||||
|
t.Fatalf("order: %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err = repo.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
check(r)
|
||||||
|
backup, err := r.ExportPartial([]string{"speedLimits"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dest.Close()
|
||||||
|
if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade
|
||||||
|
// database and inject a write failure. All observations use public interfaces.
|
||||||
|
func TestDisplayOrderLifecycleDialects(t *testing.T) {
|
||||||
|
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||||
|
t.Run(dialect, func(t *testing.T) {
|
||||||
|
open := orderTestDatabase(t, dialect)
|
||||||
|
r := open()
|
||||||
|
for _, resource := range []string{"user", "speed_limit"} {
|
||||||
|
t.Run(resource, func(t *testing.T) {
|
||||||
|
create := func(name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
var err error
|
||||||
|
if resource == "user" {
|
||||||
|
id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0)
|
||||||
|
} else {
|
||||||
|
id, err = r.CreateSpeedLimit(name, 123, 0, 1)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
list := func(db *repo.Repository) []int64 {
|
||||||
|
t.Helper()
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
var err error
|
||||||
|
if resource == "user" {
|
||||||
|
rows, err = db.ListUsers()
|
||||||
|
} else {
|
||||||
|
rows, err = db.ListSpeedLimits()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := []int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, row["id"].(int64))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
save := func(db *repo.Repository, ids, expected []int64) error {
|
||||||
|
if resource == "user" {
|
||||||
|
return db.UpdateUserDisplayOrder(ids, expected)
|
||||||
|
}
|
||||||
|
return db.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||||
|
}
|
||||||
|
a, b, c := create("a"), create("b"), create("c")
|
||||||
|
// Drop the new column to reproduce a database last opened by the old release.
|
||||||
|
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r = open()
|
||||||
|
if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||||
|
t.Fatalf("legacy order %v", got)
|
||||||
|
}
|
||||||
|
if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backup, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
restore := orderTestDatabase(t, dialect)()
|
||||||
|
defer restore.Close()
|
||||||
|
types := []string{"speedLimits"}
|
||||||
|
if resource == "user" {
|
||||||
|
types = []string{"users"}
|
||||||
|
}
|
||||||
|
if _, err := restore.Import(backup, types); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||||
|
t.Fatalf("new backup order: %v", got)
|
||||||
|
}
|
||||||
|
before, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r = open()
|
||||||
|
second := open()
|
||||||
|
defer second.Close()
|
||||||
|
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||||
|
t.Fatalf("cross-client/restart order %v", got)
|
||||||
|
}
|
||||||
|
// The next save fails on its second row; the first row must roll back.
|
||||||
|
if dialect == "sqlite" {
|
||||||
|
if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil {
|
||||||
|
t.Fatal("expected injected failure")
|
||||||
|
}
|
||||||
|
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||||
|
t.Fatalf("partial write: %v", got)
|
||||||
|
}
|
||||||
|
if dialect == "sqlite" {
|
||||||
|
if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exactly one cross-client save against the same observed order may win.
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for _, db := range []*repo.Repository{r, second} {
|
||||||
|
go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
e1, e2 := <-results, <-results
|
||||||
|
if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) {
|
||||||
|
t.Fatalf("concurrent saves: %v / %v", e1, e2)
|
||||||
|
}
|
||||||
|
// Reordering must not mutate any exported fields besides rank.
|
||||||
|
after, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
strip := func(b *model.BackupData) {
|
||||||
|
b.ExportedAt = 0
|
||||||
|
for i := range b.Users {
|
||||||
|
b.Users[i].DisplayOrder = nil
|
||||||
|
}
|
||||||
|
for i := range b.SpeedLimits {
|
||||||
|
b.SpeedLimits[i].DisplayOrder = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strip(before)
|
||||||
|
strip(after)
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
t.Fatal("reorder modified non-order backup fields")
|
||||||
|
}
|
||||||
|
// Old backups omit ranks and must not overwrite an existing saved order.
|
||||||
|
legacy := orderTestDatabase(t, dialect)()
|
||||||
|
defer legacy.Close()
|
||||||
|
if _, err := legacy.Import(after, types); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||||
|
t.Fatalf("new legacy records: %v", got)
|
||||||
|
}
|
||||||
|
if _, err := r.Import(after, types); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) {
|
||||||
|
t.Fatalf("old backup reset existing order: %v", got)
|
||||||
|
}
|
||||||
|
d := create("d")
|
||||||
|
if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) {
|
||||||
|
t.Fatalf("create after reorder: %v", got)
|
||||||
|
}
|
||||||
|
if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||||
|
t.Fatalf("stale membership: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository {
|
||||||
|
t.Helper()
|
||||||
|
if dialect == "sqlite" {
|
||||||
|
path := filepath.Join(t.TempDir(), "lifecycle.db")
|
||||||
|
return func() *repo.Repository {
|
||||||
|
r, err := repo.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { r.Close() })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts")
|
||||||
|
}
|
||||||
|
admin, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() })
|
||||||
|
scoped, err := withSearchPath(dsn, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return func() *repo.Repository {
|
||||||
|
r, err := repo.OpenPostgres(scoped)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { r.Close() })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayOrderConcurrentCreate(t *testing.T) {
|
||||||
|
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||||
|
t.Run(dialect, func(t *testing.T) {
|
||||||
|
for _, resource := range []string{"user", "speed-limit"} {
|
||||||
|
t.Run(resource, func(t *testing.T) {
|
||||||
|
open := orderTestDatabase(t, dialect)
|
||||||
|
r, other := open(), open()
|
||||||
|
create := func(db *repo.Repository, name string) (int64, error) {
|
||||||
|
if resource == "user" {
|
||||||
|
return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||||
|
}
|
||||||
|
return db.CreateSpeedLimit(name, 100, 0, 1)
|
||||||
|
}
|
||||||
|
save := func(ids, expected []int64) error {
|
||||||
|
if resource == "user" {
|
||||||
|
return r.UpdateUserDisplayOrder(ids, expected)
|
||||||
|
}
|
||||||
|
return r.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||||
|
}
|
||||||
|
a, err := create(r, "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := create(r, "b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
saved := make(chan error, 1)
|
||||||
|
type creation struct {
|
||||||
|
id int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
created := make(chan creation, 1)
|
||||||
|
go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }()
|
||||||
|
go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }()
|
||||||
|
close(start)
|
||||||
|
saveErr, c := <-saved, <-created
|
||||||
|
if c.err != nil {
|
||||||
|
t.Fatal(c.err)
|
||||||
|
}
|
||||||
|
if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) {
|
||||||
|
t.Fatalf("concurrent save: %v", saveErr)
|
||||||
|
}
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
if resource == "user" {
|
||||||
|
rows, err = r.ListUsers()
|
||||||
|
} else {
|
||||||
|
rows, err = r.ListSpeedLimits()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := []int64{}
|
||||||
|
for _, row := range rows {
|
||||||
|
got = append(got, row["id"].(int64))
|
||||||
|
}
|
||||||
|
want := []int64{c.id, a, b}
|
||||||
|
if saveErr != nil {
|
||||||
|
want = []int64{c.id, b, a}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("concurrent create order %v want %v", got, want)
|
||||||
|
}
|
||||||
|
if resource == "user" {
|
||||||
|
err = r.DeleteUserCascade(c.id)
|
||||||
|
} else {
|
||||||
|
err = r.DeleteSpeedLimit(c.id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||||
|
t.Fatalf("deleted membership not rejected: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
httpserver "go-backend/internal/http"
|
||||||
|
"go-backend/internal/http/handler"
|
||||||
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardOwnerNamesFollowAccountRenames(t *testing.T) {
|
||||||
|
for _, database := range []string{"sqlite", "postgres"} {
|
||||||
|
t.Run(database, func(t *testing.T) {
|
||||||
|
for _, mode := range []string{"admin-name-only", "admin-name-and-password", "self-name-and-password"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
const secret = "issue574-isolated-test"
|
||||||
|
open := ownerNamesRepository(t, database)
|
||||||
|
r := open()
|
||||||
|
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
||||||
|
for _, u := range []model.User{
|
||||||
|
{ID: 9103, User: "before-name", Pwd: security.MD5("old-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
|
||||||
|
{ID: 9104, User: "other-user", Pwd: security.MD5("other-password"), RoleID: 1, Status: 1, PasswordChangedAt: time.Now().Add(-10 * time.Second).UnixMilli()},
|
||||||
|
} {
|
||||||
|
if err := r.DB().Create(&u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exp := time.Now().Add(time.Hour).UnixMilli()
|
||||||
|
f := model.Forward{ID: 9301, UserID: 9103, UserName: "before-name", Name: "existing-rule", TunnelID: 9201, RemoteAddr: "127.0.0.1:8080", Status: 1, InFlow: 123, OutFlow: 456}
|
||||||
|
for _, v := range []interface{}{&model.Tunnel{ID: 9201, Name: "isolated", Type: 1, Protocol: "tls", Status: 1, Flow: 1}, &model.UserTunnel{ID: 9401, UserID: 9103, TunnelID: 9201, Status: 1, Flow: 100, Num: 10, ExpTime: exp}, &f} {
|
||||||
|
if err := r.DB().Create(v).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := func(id int64, name string, role int) string {
|
||||||
|
v, e := auth.GenerateTokenAt(id, name, role, secret, time.Now().Add(time.Second))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
admin := token(1, "admin_user", 0)
|
||||||
|
oldToken, e := auth.GenerateTokenAt(9103, "before-name", 1, secret, time.Now().Add(-time.Second))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
call := func(path, tok string, body interface{}) (int, json.RawMessage) {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
q := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
q.Header.Set("Authorization", tok)
|
||||||
|
q.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, q)
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
return out.Code, out.Data
|
||||||
|
}
|
||||||
|
path, tok := "/api/v1/user/update", admin
|
||||||
|
body := map[string]interface{}{"id": 9103, "user": "after-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}
|
||||||
|
if mode == "admin-name-and-password" {
|
||||||
|
body["pwd"] = "new-password"
|
||||||
|
}
|
||||||
|
if mode == "self-name-and-password" {
|
||||||
|
path = "/api/v1/user/updatePassword"
|
||||||
|
tok = oldToken
|
||||||
|
body = map[string]interface{}{"newUsername": "after-name", "currentPassword": "old-password", "newPassword": "new-password", "confirmPassword": "new-password"}
|
||||||
|
}
|
||||||
|
if code, _ := call(path, tok, body); code != 0 {
|
||||||
|
t.Fatalf("rename code=%d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh := token(9103, "after-name", 1)
|
||||||
|
code, data := call("/api/v1/forward/list", admin, map[string]interface{}{})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatal(code)
|
||||||
|
}
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0]["userName"] != "after-name" || rows[0]["userId"] != float64(9103) {
|
||||||
|
t.Fatalf("expected current owner name: %s", data)
|
||||||
|
}
|
||||||
|
if rows[0]["id"] != float64(9301) || rows[0]["inFlow"] != float64(123) || rows[0]["outFlow"] != float64(456) || rows[0]["status"] != float64(1) {
|
||||||
|
t.Fatalf("rule identity/traffic/status changed: %s", data)
|
||||||
|
}
|
||||||
|
if code, data = call("/api/v1/forward/list", fresh, map[string]interface{}{}); code != 0 {
|
||||||
|
t.Fatalf("owner denied: %d", code)
|
||||||
|
}
|
||||||
|
rows = nil
|
||||||
|
if err := json.Unmarshal(data, &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0]["userName"] != "after-name" {
|
||||||
|
t.Fatalf("owner cannot see renamed rule: %s", data)
|
||||||
|
}
|
||||||
|
code, data = call("/api/v1/tunnel/delete-preview", admin, map[string]interface{}{"id": 9201})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("preview code=%d", code)
|
||||||
|
}
|
||||||
|
var preview struct {
|
||||||
|
SampleForwards []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"userId"`
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
} `json:"sampleForwards"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &preview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(preview.SampleForwards) != 1 || preview.SampleForwards[0].ID != 9301 || preview.SampleForwards[0].UserID != 9103 || preview.SampleForwards[0].UserName != "after-name" {
|
||||||
|
t.Fatalf("preview must show current owner: %s", data)
|
||||||
|
}
|
||||||
|
code, data = call("/api/v1/tunnel/batch-delete-preview", admin, map[string]interface{}{"ids": []int64{9201}})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("batch preview code=%d", code)
|
||||||
|
}
|
||||||
|
var batch struct {
|
||||||
|
Items []struct {
|
||||||
|
SampleForwards []struct {
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
} `json:"sampleForwards"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &batch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(batch.Items) != 1 || len(batch.Items[0].SampleForwards) != 1 || batch.Items[0].SampleForwards[0].UserName != "after-name" {
|
||||||
|
t.Fatalf("batch preview must show current owner: %s", data)
|
||||||
|
}
|
||||||
|
exported, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(exported.UserTunnels) != 1 || exported.UserTunnels[0].UserID != 9103 || exported.UserTunnels[0].TunnelID != 9201 || exported.UserTunnels[0].Flow != 100 || exported.UserTunnels[0].Status != 1 {
|
||||||
|
t.Fatalf("rename changed tunnel grant: %+v", exported.UserTunnels)
|
||||||
|
}
|
||||||
|
oldCode, _ := call("/api/v1/forward/list", oldToken, map[string]interface{}{})
|
||||||
|
wantOld := 0
|
||||||
|
if mode != "admin-name-only" {
|
||||||
|
wantOld = 401
|
||||||
|
}
|
||||||
|
if oldCode != wantOld {
|
||||||
|
t.Fatalf("old token=%d want %d", oldCode, wantOld)
|
||||||
|
}
|
||||||
|
// Re-use the abandoned username for another identity; it must not acquire rules.
|
||||||
|
if code, _ := call("/api/v1/user/update", admin, map[string]interface{}{"id": 9104, "user": "before-name", "flow": 100, "num": 10, "expTime": exp, "flowResetTime": 1, "status": 1, "maxConn": 0}); code != 0 {
|
||||||
|
t.Fatalf("reuse old name: %d", code)
|
||||||
|
}
|
||||||
|
other := token(9104, "before-name", 1)
|
||||||
|
code, data = call("/api/v1/forward/list", other, map[string]interface{}{})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatal(code)
|
||||||
|
}
|
||||||
|
rows = nil
|
||||||
|
if err := json.Unmarshal(data, &rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("old name gained rules: %s", data)
|
||||||
|
}
|
||||||
|
if code, _ := call("/api/v1/forward/reset-flow", other, map[string]interface{}{"id": 9301}); code == 0 {
|
||||||
|
t.Fatal("other user reset original rule")
|
||||||
|
}
|
||||||
|
if code, _ := call("/api/v1/forward/reset-flow", fresh, map[string]interface{}{"id": 9301}); code != 0 {
|
||||||
|
t.Fatalf("owner reset denied: %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each PostgreSQL test owns only its unique schema, never the shared test database.
|
||||||
|
func ownerNamesRepository(t *testing.T, database string) func() *repo.Repository {
|
||||||
|
t.Helper()
|
||||||
|
dsn := filepath.Join(t.TempDir(), "owners.db")
|
||||||
|
if database == "postgres" {
|
||||||
|
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
|
||||||
|
if baseDSN == "" {
|
||||||
|
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres owner-name contracts")
|
||||||
|
}
|
||||||
|
schema := "contract_owner_names_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
db, err := sql.Open("pgx", baseDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = db.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
|
||||||
|
db.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = db.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`); _ = db.Close() })
|
||||||
|
dsn, err = withSearchPath(baseDSN, schema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func() *repo.Repository {
|
||||||
|
t.Helper()
|
||||||
|
var r *repo.Repository
|
||||||
|
var err error
|
||||||
|
if database == "postgres" {
|
||||||
|
r, err = repo.OpenPostgres(dsn)
|
||||||
|
} else {
|
||||||
|
r, err = repo.Open(dsn)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardOwnerNamesSurviveLegacyBackupAndRestart(t *testing.T) {
|
||||||
|
for _, database := range []string{"sqlite", "postgres"} {
|
||||||
|
t.Run(database, func(t *testing.T) {
|
||||||
|
open := ownerNamesRepository(t, database)
|
||||||
|
r := open()
|
||||||
|
// A legacy backup stores the rule's original name, not the user's current one.
|
||||||
|
const legacy = `{"users":[{"id":9103,"user":"current-owner","pwd":"test-only-password","roleId":1,"status":1}],
|
||||||
|
"forwards":[
|
||||||
|
{"id":9301,"userId":9103,"userName":"former-owner","name":"historical-rule","tunnelId":9201,"inFlow":123,"outFlow":456,"status":1},
|
||||||
|
{"id":9302,"userId":9199,"userName":"missing-owner","name":"orphan-rule","tunnelId":9201,"status":1},
|
||||||
|
{"id":9303,"userId":9198,"userName":"","name":"unknown-owner-rule","tunnelId":9201,"status":1}]}`
|
||||||
|
var backup model.BackupData
|
||||||
|
if err := json.Unmarshal([]byte(legacy), &backup); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Import(&backup, []string{"users", "forwards"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check := func(stage string) {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := r.ListForwards()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("%s: rows=%+v", stage, rows)
|
||||||
|
}
|
||||||
|
for i, want := range []string{"current-owner", "missing-owner", ""} {
|
||||||
|
if rows[i]["userName"] != want {
|
||||||
|
t.Fatalf("%s: row %d owner=%v want %q", stage, i, rows[i]["userName"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows[0]["id"] != int64(9301) || rows[0]["userId"] != int64(9103) || rows[0]["inFlow"] != int64(123) || rows[0]["outFlow"] != int64(456) {
|
||||||
|
t.Fatalf("%s: rule changed: %+v", stage, rows[0])
|
||||||
|
}
|
||||||
|
previews, err := r.ListForwardsByTunnel(9201)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(previews) != 3 || previews[0].UserName != "current-owner" || previews[1].UserName != "missing-owner" || previews[2].UserName != "" {
|
||||||
|
t.Fatalf("%s: preview owners=%+v", stage, previews)
|
||||||
|
}
|
||||||
|
exported, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(exported.Forwards) != 3 || exported.Forwards[0].UserName != "former-owner" {
|
||||||
|
t.Fatalf("%s: stored legacy snapshot was rewritten: %+v", stage, exported.Forwards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("import")
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r = open()
|
||||||
|
check("restart")
|
||||||
|
// A forwards-only restore must resolve against the existing user by ID.
|
||||||
|
if _, err := r.Import(&backup, []string{"forwards"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check("partial restore")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardEntryRequiresTunnelChainContract(t *testing.T) {
|
||||||
|
for _, tunnelType := range []int{1, 2} {
|
||||||
|
t.Run(fmt.Sprintf("type-%d", tunnelType), func(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := repo.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(202, 'forward_bind_retry_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-bind-retry-tunnel", 1.0, tunnelType, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "forward-bind-retry-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "forward-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tunnelType == 2 {
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 3, ?, 44002, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, nodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert exit chain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.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(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||||
|
`, tunnelID).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createPayload := map[string]interface{}{
|
||||||
|
"name": "forward-bind-retry-target",
|
||||||
|
"tunnelId": tunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
}
|
||||||
|
createBody, err := json.Marshal(createPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal create payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var emitted []map[string]interface{}
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
if kind == "AddService" || kind == "UpdateService" {
|
||||||
|
var services []map[string]interface{}
|
||||||
|
if json.Unmarshal(data, &services) == nil {
|
||||||
|
mu.Lock()
|
||||||
|
emitted = append(emitted, services...)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
waitNodeStatus(t, repo, nodeID, 1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
found := 0
|
||||||
|
for _, svc := range emitted {
|
||||||
|
h, ok := svc["handler"].(map[string]interface{})
|
||||||
|
if !ok || (h["type"] != "tcp" && h["type"] != "udp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found++
|
||||||
|
md, _ := h["metadata"].(map[string]interface{})
|
||||||
|
if tunnelType == 2 {
|
||||||
|
if h["chain"] != fmt.Sprintf("chains_%d", tunnelID) || md["chain.strict"] != true {
|
||||||
|
t.Fatalf("tunnel forward must require chain, got %v", h)
|
||||||
|
}
|
||||||
|
} else if md["chain.strict"] == true {
|
||||||
|
t.Fatal("direct forward cannot require a tunnel chain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 2 {
|
||||||
|
t.Fatalf("expected TCP and UDP forwarding commands, got %d", found)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
|
|||||||
if status["enabled"] != false {
|
if status["enabled"] != false {
|
||||||
t.Fatalf("invalid origin enabled passkeys: %v", status)
|
t.Fatalf("invalid origin enabled passkeys: %v", status)
|
||||||
}
|
}
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-disabled"}); result.Code == 0 {
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}); result.Code == 0 {
|
||||||
t.Fatal("login begin succeeded with invalid origin")
|
t.Fatal("login begin succeeded with invalid origin")
|
||||||
}
|
}
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
|
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
|
||||||
@@ -70,7 +70,7 @@ func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
|
func TestPasskeyRegistrationAndOwnership(t *testing.T) {
|
||||||
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
|
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
|
||||||
router, r := setupContractRouter(t, "passkey-test-secret")
|
router, r := setupContractRouter(t, "passkey-test-secret")
|
||||||
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
|
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
|
||||||
@@ -90,6 +90,10 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// The failed cross-user attempt consumes the challenge.
|
// The failed cross-user attempt consumes the challenge.
|
||||||
begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
|
begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
|
||||||
|
selection := begin["options"].(map[string]interface{})["publicKey"].(map[string]interface{})["authenticatorSelection"].(map[string]interface{})
|
||||||
|
if selection["residentKey"] != "required" || selection["requireResidentKey"] != true {
|
||||||
|
t.Fatalf("registration did not require a discoverable credential: %v", selection)
|
||||||
|
}
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -113,45 +117,148 @@ func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
|
|||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 {
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 {
|
||||||
t.Fatal("Bob deleted Alice's credential")
|
t.Fatal("Bob deleted Alice's credential")
|
||||||
}
|
}
|
||||||
|
|
||||||
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
|
|
||||||
bad := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), "https://wrong.example.test", "panel.example.test", true, 1)
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": bad}); result.Code == 0 {
|
|
||||||
t.Fatal("wrong origin was accepted")
|
|
||||||
}
|
|
||||||
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
|
|
||||||
assertion := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
|
|
||||||
loginBody := map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}
|
|
||||||
loginResult := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody)
|
|
||||||
if passkeyData(t, loginResult)["token"] == "" {
|
|
||||||
t.Fatal("passkey login returned no JWT")
|
|
||||||
}
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody); result.Code == 0 {
|
|
||||||
t.Fatal("login challenge was reusable")
|
|
||||||
}
|
|
||||||
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
|
|
||||||
badRP := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "wrong.example.test", true, 2)
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": badRP}); result.Code == 0 {
|
|
||||||
t.Fatal("wrong RP ID was accepted")
|
|
||||||
}
|
|
||||||
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
|
|
||||||
noUV := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", false, 2)
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": noUV}); result.Code == 0 {
|
|
||||||
t.Fatal("assertion without user verification was accepted")
|
|
||||||
}
|
|
||||||
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
|
|
||||||
reusedCounter := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": reusedCounter}); result.Code == 0 {
|
|
||||||
t.Fatal("reused nonzero signature counter was accepted")
|
|
||||||
}
|
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 {
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 {
|
||||||
t.Fatal("delete did not require password re-verification")
|
t.Fatal("delete did not require password re-verification")
|
||||||
}
|
}
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 {
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 {
|
||||||
t.Fatalf("delete: %s", result.Msg)
|
t.Fatalf("delete: %s", result.Msg)
|
||||||
}
|
}
|
||||||
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}); result.Code == 0 {
|
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}))
|
||||||
t.Fatal("deleted credential could still start login")
|
aliceHandle := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(aliceHandle, 9302)
|
||||||
|
assertion := makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1, aliceHandle)
|
||||||
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}); result.Code == 0 {
|
||||||
|
t.Fatal("deleted credential could still complete login")
|
||||||
|
}
|
||||||
|
// Deletion must not prevent the same account from binding a new key.
|
||||||
|
rebind := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
|
||||||
|
newID := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(newID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newResponse := makeRegistrationResponse(t, privateKey, newID, passkeyChallenge(t, rebind), passkeyTestOrigin)
|
||||||
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": rebind["sessionId"], "credential": newResponse}); result.Code != 0 {
|
||||||
|
t.Fatalf("rebind after delete: %s", result.Msg)
|
||||||
|
}
|
||||||
|
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-alice", "password": "alice-password"}); result.Code != 0 {
|
||||||
|
t.Fatalf("password login regressed after enabling passkeys: %s", result.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyDiscoverableLoginAndOwnership(t *testing.T) {
|
||||||
|
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
|
||||||
|
router, r := setupContractRouter(t, "passkey-test-secret")
|
||||||
|
seedLegacyUser(t, r, 9311, "discover-alice", "alice-password")
|
||||||
|
seedLegacyUser(t, r, 9312, "discover-bob", "bob-password")
|
||||||
|
aliceToken, _ := auth.GenerateToken(9311, "discover-alice", 1, "passkey-test-secret")
|
||||||
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
credentialID := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(credentialID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
|
||||||
|
registration := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, register), passkeyTestOrigin)
|
||||||
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, map[string]interface{}{"sessionId": register["sessionId"], "credential": registration}); result.Code != 0 {
|
||||||
|
t.Fatalf("register: %s", result.Msg)
|
||||||
|
}
|
||||||
|
aliceHandle := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(aliceHandle, 9311)
|
||||||
|
bobHandle := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(bobHandle, 9312)
|
||||||
|
begin := func(body interface{}) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
return passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", body))
|
||||||
|
}
|
||||||
|
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "discover-alice"}); result.Code == 0 {
|
||||||
|
t.Fatal("username-based passkey fallback was accepted")
|
||||||
|
}
|
||||||
|
finish := func(session map[string]interface{}, assertion map[string]interface{}) response.R {
|
||||||
|
t.Helper()
|
||||||
|
return passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": session["sessionId"], "credential": assertion})
|
||||||
|
}
|
||||||
|
assertionFor := func(session map[string]interface{}, handle []byte, origin, rpID string, uv bool, count uint32) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
return makeAssertionResponseWithHandle(t, privateKey, credentialID, passkeyChallenge(t, session), origin, rpID, uv, count, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No username and no allowCredentials let the authenticator offer an account.
|
||||||
|
session := begin(map[string]string{})
|
||||||
|
options := session["options"].(map[string]interface{})["publicKey"].(map[string]interface{})
|
||||||
|
if list, exists := options["allowCredentials"]; exists && list != nil {
|
||||||
|
t.Fatalf("discoverable login unexpectedly restricted credentials: %v", list)
|
||||||
|
}
|
||||||
|
if options["userVerification"] != "required" {
|
||||||
|
t.Fatalf("discoverable login did not require UV: %v", options)
|
||||||
|
}
|
||||||
|
wrongHandle := assertionFor(session, bobHandle, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
if result := finish(session, wrongHandle); result.Code == 0 {
|
||||||
|
t.Fatal("Bob's user handle selected Alice's credential")
|
||||||
|
}
|
||||||
|
if result := finish(session, wrongHandle); result.Code == 0 {
|
||||||
|
t.Fatal("failed discoverable challenge was reusable")
|
||||||
|
}
|
||||||
|
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
// Older preferred resident-key registrations may have no discoverable userHandle.
|
||||||
|
missingHandle := assertionFor(session, nil, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
if result := finish(session, missingHandle); result.Code == 0 {
|
||||||
|
t.Fatal("discoverable login accepted a missing user handle")
|
||||||
|
}
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
unknownID := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
unknownID["id"] = base64.RawURLEncoding.EncodeToString([]byte("unknown-credential"))
|
||||||
|
unknownID["rawId"] = unknownID["id"]
|
||||||
|
if result := finish(session, unknownID); result.Code == 0 {
|
||||||
|
t.Fatal("unknown credential ID selected Alice's account")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
origin string
|
||||||
|
rpID string
|
||||||
|
uv bool
|
||||||
|
}{
|
||||||
|
{"origin", "https://wrong.example.test", "panel.example.test", true},
|
||||||
|
{"RP ID", passkeyTestOrigin, "wrong.example.test", true},
|
||||||
|
{"user verification", passkeyTestOrigin, "panel.example.test", false},
|
||||||
|
} {
|
||||||
|
session := begin(map[string]string{})
|
||||||
|
assertion := assertionFor(session, aliceHandle, test.origin, test.rpID, test.uv, 1)
|
||||||
|
if result := finish(session, assertion); result.Code == 0 {
|
||||||
|
t.Fatalf("invalid discoverable assertion was accepted: %s", test.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
badSignature := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
badSignature["response"].(map[string]interface{})["signature"] = base64.RawURLEncoding.EncodeToString([]byte("invalid-signature"))
|
||||||
|
if result := finish(session, badSignature); result.Code == 0 {
|
||||||
|
t.Fatal("invalid signature was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
valid := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
if got := passkeyData(t, finish(session, valid))["name"]; got != "discover-alice" {
|
||||||
|
t.Fatalf("discoverable login selected the wrong account: %v", got)
|
||||||
|
}
|
||||||
|
if result := finish(session, valid); result.Code == 0 {
|
||||||
|
t.Fatal("discoverable assertion was replayed")
|
||||||
|
}
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
reusedCounter := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 1)
|
||||||
|
if result := finish(session, reusedCounter); result.Code == 0 {
|
||||||
|
t.Fatal("discoverable login accepted a reused nonzero signature counter")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec("UPDATE user SET status = 0 WHERE id = ?", 9311).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session = begin(map[string]string{})
|
||||||
|
disabled := assertionFor(session, aliceHandle, passkeyTestOrigin, "panel.example.test", true, 2)
|
||||||
|
if result := finish(session, disabled); result.Code == 0 {
|
||||||
|
t.Fatal("disabled account logged in with a discoverable credential")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +287,10 @@ func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []b
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} {
|
func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} {
|
||||||
|
return makeAssertionResponseWithHandle(t, privateKey, id, challenge, origin, rpID, verified, count, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAssertionResponseWithHandle(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32, handle []byte) map[string]interface{} {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rpHash := sha256.Sum256([]byte(rpID))
|
rpHash := sha256.Sum256([]byte(rpID))
|
||||||
authData := append([]byte{}, rpHash[:]...)
|
authData := append([]byte{}, rpHash[:]...)
|
||||||
@@ -197,5 +308,9 @@ func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": nil}}
|
var userHandle interface{}
|
||||||
|
if handle != nil {
|
||||||
|
userHandle = base64.RawURLEncoding.EncodeToString(handle)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": userHandle}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
httpserver "go-backend/internal/http"
|
||||||
|
"go-backend/internal/http/handler"
|
||||||
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresBackupImportAllowsImmediateCreate(t *testing.T) {
|
||||||
|
source := ownerNamesRepository(t, "postgres")()
|
||||||
|
for _, name := range []string{"backup-one", "backup-two"} {
|
||||||
|
if _, err := source.CreateUser(name, security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := source.CreateSpeedLimit(name, 100, 0, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backup, err := source.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := ownerNamesRepository(t, "postgres")()
|
||||||
|
if _, err := target.Import(backup, []string{"users", "speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id, err := target.CreateUser("after-import", security.MD5("test-password"), 1, 0, 10, 1, 2, 1, 3, 0); err != nil || id <= 2 {
|
||||||
|
t.Errorf("create user immediately after backup import: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
if id, err := target.CreateSpeedLimit("after-import", 100, 0, 1); err != nil || id <= 2 {
|
||||||
|
t.Errorf("create speed limit immediately after backup import: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportPreservesAllocatedIDs(t *testing.T) {
|
||||||
|
for _, called := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("called=%t", called), func(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
// A deleted or rolled-back allocation may be higher than every row.
|
||||||
|
for _, table := range []string{"user", "speed_limit"} {
|
||||||
|
if err := r.DB().Exec(`SELECT setval(pg_get_serial_sequence(?, 'id'), 500, ?)`, `"`+table+`"`, called).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := &model.BackupData{Users: []model.UserBackup{{ID: 1, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}}, SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "restored"}}}
|
||||||
|
if _, err := r.Import(b, []string{"users", "speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := int64(500)
|
||||||
|
if called {
|
||||||
|
want++
|
||||||
|
}
|
||||||
|
if id, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != want {
|
||||||
|
t.Fatalf("user id=%d, want %d; err=%v", id, want, err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != want {
|
||||||
|
t.Fatalf("speed limit id=%d, want %d; err=%v", id, want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportInvalidatesCachedIDs(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq CACHE 20`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, err := r.DB().DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cached, err := pool.Conn(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cached.Close()
|
||||||
|
// Hold this backend out of the pool with unused cached IDs 2..20.
|
||||||
|
var allocated int64
|
||||||
|
if err := cached.QueryRowContext(context.Background(), `SELECT nextval('speed_limit_id_seq')`).Scan(&allocated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if allocated != 1 {
|
||||||
|
t.Fatal(allocated)
|
||||||
|
}
|
||||||
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 2, Name: "restored"}}}
|
||||||
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Return only the cached backend to the pool, so the public create must use it.
|
||||||
|
pool.SetMaxIdleConns(0)
|
||||||
|
pool.SetMaxIdleConns(1)
|
||||||
|
if err := cached.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id <= 20 {
|
||||||
|
t.Fatalf("cached ID reused: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportRollsBackSequenceOnCommitFailure(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
// Deferred validation fails after import has already advanced the sequence.
|
||||||
|
for _, query := range []string{
|
||||||
|
`CREATE FUNCTION reject_import_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'reject imported row at commit'; END $$`,
|
||||||
|
`CREATE CONSTRAINT TRIGGER reject_import_commit AFTER INSERT ON speed_limit DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name = 'fail-at-commit') EXECUTE FUNCTION reject_import_commit()`,
|
||||||
|
} {
|
||||||
|
if err := r.DB().Exec(query).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "fail-at-commit"}}}
|
||||||
|
if _, err := r.Import(b, []string{"speedLimits"}); err == nil || !strings.Contains(err.Error(), "reject imported row at commit") {
|
||||||
|
t.Fatalf("expected commit failure, got %v", err)
|
||||||
|
}
|
||||||
|
exported, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(exported.SpeedLimits) != 0 {
|
||||||
|
t.Fatal("failed import persisted rows")
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||||
|
t.Fatalf("failed import changed next allocation: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportPreservesSequenceIncrement(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq INCREMENT BY 10`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 25, Name: "restored"}}}
|
||||||
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 31 {
|
||||||
|
t.Fatalf("changed ID progression: id=%d, want 31; err=%v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportAdvancesEveryExplicitIDTable(t *testing.T) {
|
||||||
|
source := ownerNamesRepository(t, "postgres")()
|
||||||
|
types := []string{"users", "speedLimits", "nodes", "tunnels", "forwards", "userTunnels", "tunnelGroups", "userGroups", "permissions", "configs"}
|
||||||
|
fixture := &model.BackupData{
|
||||||
|
Users: []model.UserBackup{{ID: 1, User: "old", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||||
|
SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "old"}},
|
||||||
|
Nodes: []model.NodeBackup{{ID: 1, Name: "old", Secret: "old-node"}},
|
||||||
|
Tunnels: []model.TunnelBackup{{ID: 1, Name: "old", ChainTunnels: []model.ChainTunnelBackup{{ID: 1, TunnelID: 1, NodeID: 1, ChainType: "1"}}}},
|
||||||
|
Forwards: []model.ForwardBackup{{ID: 1, Name: "old", UserID: 1, TunnelID: 1}},
|
||||||
|
UserTunnels: []model.UserTunnelBackup{{ID: 1, UserID: 1, TunnelID: 1}},
|
||||||
|
TunnelGroups: []model.TunnelGroupBackup{{ID: 1, Name: "old"}},
|
||||||
|
UserGroups: []model.UserGroupBackup{{ID: 1, Name: "old"}},
|
||||||
|
Permissions: []model.PermissionBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, Grants: []model.PermissionGrantBackup{{ID: 1, UserGroupID: 1, TunnelGroupID: 1, UserTunnelID: 1}}}},
|
||||||
|
}
|
||||||
|
if _, err := source.Import(fixture, types); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backup, err := source.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
if _, err := r.Import(backup, types); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Use the normal public creation methods, including the nested child tables.
|
||||||
|
for _, create := range []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{"user", func() error { _, err := r.CreateUser("new", "hash", 1, 0, 0, 0, 0, 1, 0, 0); return err }},
|
||||||
|
{"speed limit", func() error { _, err := r.CreateSpeedLimit("new", 100, 0, 1); return err }},
|
||||||
|
{"node", func() error {
|
||||||
|
return r.CreateRemoteNode("new", "new-node", "127.0.0.1", "10000-20000", 0, 1, 0, "", "", "")
|
||||||
|
}},
|
||||||
|
{"tunnel", func() error { _, err := r.CreateTunnelTx(r.DB(), "new", 1, 1, 0, 0, 1, nil, 0, "", "", 0); return err }},
|
||||||
|
{"chain tunnel", func() error { return r.CreateChainTunnelTx(r.DB(), 1, "1", 2, sql.NullInt64{}, "", 1, "tls", "") }},
|
||||||
|
{"forward", func() error {
|
||||||
|
_, err := r.CreateForwardTx(1, "old", "new", 1, "127.0.0.1:80", "", 0, 0, nil, 0, "", nil, 0, 0, nil, 0, 0, 0)
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"user tunnel", func() error { return r.InsertUserTunnel(2, 1, nil, 0, 0, 0, 0, 1) }},
|
||||||
|
{"tunnel group", func() error { return r.GroupCreate("tunnel_group", "new", 1, 0) }},
|
||||||
|
{"user group", func() error { return r.GroupCreate("user_group", "new", 1, 0) }},
|
||||||
|
{"permission", func() error { return r.InsertGroupPermission(2, 2, 0) }},
|
||||||
|
{"permission grant", func() error { r.InsertGroupPermissionGrant(1, 1, 2, 1, 0); return nil }},
|
||||||
|
} {
|
||||||
|
t.Run(create.name, func(t *testing.T) {
|
||||||
|
if err := create.run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
got, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := map[string][]int64{}
|
||||||
|
for _, v := range got.Users {
|
||||||
|
ids["user"] = append(ids["user"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.SpeedLimits {
|
||||||
|
ids["speed limit"] = append(ids["speed limit"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.Nodes {
|
||||||
|
ids["node"] = append(ids["node"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.Tunnels {
|
||||||
|
ids["tunnel"] = append(ids["tunnel"], v.ID)
|
||||||
|
for _, child := range v.ChainTunnels {
|
||||||
|
ids["chain tunnel"] = append(ids["chain tunnel"], child.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range got.Forwards {
|
||||||
|
ids["forward"] = append(ids["forward"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.UserTunnels {
|
||||||
|
ids["user tunnel"] = append(ids["user tunnel"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.TunnelGroups {
|
||||||
|
ids["tunnel group"] = append(ids["tunnel group"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.UserGroups {
|
||||||
|
ids["user group"] = append(ids["user group"], v.ID)
|
||||||
|
}
|
||||||
|
for _, v := range got.Permissions {
|
||||||
|
ids["permission"] = append(ids["permission"], v.ID)
|
||||||
|
for _, child := range v.Grants {
|
||||||
|
ids["permission grant"] = append(ids["permission grant"], child.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) != 11 {
|
||||||
|
t.Fatalf("missing tables in export: %v", ids)
|
||||||
|
}
|
||||||
|
for name, values := range ids {
|
||||||
|
slices.Sort(values)
|
||||||
|
if !slices.Equal(values, []int64{1, 2}) {
|
||||||
|
t.Errorf("%s: expected old and new IDs [1 2], got %v", name, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportKeepsExistingRowsAndEmptySelections(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
// Fixture simulates an existing explicit-ID row with an unsynchronized sequence.
|
||||||
|
if err := r.DB().Create(&model.SpeedLimit{ID: 500, Name: "existing"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||||
|
if _, err := r.Import(old, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 501 {
|
||||||
|
t.Fatalf("existing maximum ignored: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
for _, b := range []*model.BackupData{{}, {Users: []model.UserBackup{{ID: 900, User: "not-selected", Pwd: "$2-test-fixture"}}}} {
|
||||||
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id, err := r.CreateUser("first-user", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||||
|
t.Fatalf("unselected sequence changed: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("after-empty", 100, 0, 1); err != nil || id != 502 {
|
||||||
|
t.Fatalf("empty import changed allocation: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
got, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Users) != 2 || len(got.SpeedLimits) != 4 || got.SpeedLimits[1].Name != "existing" {
|
||||||
|
t.Fatalf("existing or unselected data changed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportResolvesLegacySequenceAndSearchPath(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
pool, err := r.DB().DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool.SetMaxOpenConns(1)
|
||||||
|
var schema string
|
||||||
|
if err := r.DB().Raw(`SELECT current_schema()`).Scan(&schema).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prefix := "Empty-Prefix-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
if err := r.DB().Exec(`CREATE SCHEMA "` + prefix + `"`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = r.DB().Exec(`DROP SCHEMA "` + prefix + `" CASCADE`).Error })
|
||||||
|
// The historical default uses a custom, unowned sequence; the original
|
||||||
|
// owned serial sequence still exists but is no longer used by INSERT.
|
||||||
|
for _, query := range []string{
|
||||||
|
`CREATE SEQUENCE "Custom Limit Sequence"`,
|
||||||
|
`ALTER TABLE speed_limit ALTER COLUMN id SET DEFAULT nextval('"Custom Limit Sequence"'::regclass)`,
|
||||||
|
`SET search_path TO "` + prefix + `", "` + schema + `"`,
|
||||||
|
} {
|
||||||
|
if err := r.DB().Exec(query).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := &model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 1, Name: "legacy"}}}
|
||||||
|
if _, err := r.Import(b, []string{"speedLimits"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("new", 100, 0, 1); err != nil || id != 2 {
|
||||||
|
t.Fatalf("wrong sequence or schema: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForBackupDBCondition(t *testing.T, r *repo.Repository, query string, args ...interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var ready bool
|
||||||
|
if err := r.DB().Raw(query, args...).Scan(&ready).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("database barrier was not reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupImportBarrier(t *testing.T, r *repo.Repository, table, timing, event, condition string) (*sql.Tx, int64) {
|
||||||
|
t.Helper()
|
||||||
|
pool, err := r.DB().DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
barrier, err := pool.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = barrier.Rollback() })
|
||||||
|
key := time.Now().UnixNano() & 0x3fffffff
|
||||||
|
if _, err := barrier.Exec(`SELECT pg_advisory_xact_lock($1)`, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
guardEnd := ""
|
||||||
|
if condition != "" {
|
||||||
|
guardEnd = "END IF;"
|
||||||
|
}
|
||||||
|
function := fmt.Sprintf(`CREATE FUNCTION pause_backup_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN %s PERFORM pg_advisory_xact_lock(%d); %s RETURN NEW; END $$`, condition, key, guardEnd)
|
||||||
|
if err := r.DB().Exec(function).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trigger := fmt.Sprintf(`CREATE TRIGGER pause_backup_test %s %s ON %s FOR EACH ROW EXECUTE FUNCTION pause_backup_test()`, timing, event, table)
|
||||||
|
if err := r.DB().Exec(trigger).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return barrier, key
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportSerializesConcurrentCreatesAndImports(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
barrier, key := backupImportBarrier(t, r, "speed_limit", "BEFORE", "INSERT", "IF NEW.name = 'hold-import' THEN")
|
||||||
|
first := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 100, Name: "hold-import"}}}, []string{"speedLimits"})
|
||||||
|
first <- err
|
||||||
|
}()
|
||||||
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||||
|
created := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
id, err := r.CreateSpeedLimit("concurrent-create", 100, 0, 1)
|
||||||
|
if err == nil && id <= 100 {
|
||||||
|
err = fmt.Errorf("reused imported ID: %d", id)
|
||||||
|
}
|
||||||
|
created <- err
|
||||||
|
}()
|
||||||
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='speed_limit'::regclass AND NOT granted)`)
|
||||||
|
second := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
result, err := r.Import(&model.BackupData{SpeedLimits: []model.SpeedLimitBackup{{ID: 200, Name: "second-import"}}}, []string{"speedLimits"})
|
||||||
|
if err == nil && result.SpeedLimitsImported != 1 {
|
||||||
|
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||||
|
}
|
||||||
|
second <- err
|
||||||
|
}()
|
||||||
|
if err := barrier.Commit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, done := range map[string]<-chan error{"first import": first, "create": created, "second import": second} {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("%s blocked", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("after-both", 100, 0, 1); err != nil || id <= 200 {
|
||||||
|
t.Fatalf("second import sequence not advanced: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
got, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.SpeedLimits) != 4 {
|
||||||
|
t.Fatalf("lost concurrent rows: %+v", got.SpeedLimits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportAvoidsCascadeDeleteDeadlock(t *testing.T) {
|
||||||
|
for _, mode := range []string{"retry-success", "busy-bounded"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
user, err := r.CreateUser("delete-me", "hash", 1, 0, 0, 0, 0, 1, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forward, err := r.CreateForwardTx(user, "delete-me", "old", 1, "127.0.0.1:80", "", 0, 0, []int64{1}, 12345, "", nil, 0, 0, nil, 0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Pause a real public cascade after deleting a child row, before it
|
||||||
|
// reaches forward/user. Import must not retain partial parent locks.
|
||||||
|
barrier, key := backupImportBarrier(t, r, "forward_port", "AFTER", "DELETE", "")
|
||||||
|
deleted := make(chan error, 1)
|
||||||
|
go func() { deleted <- r.DeleteUserCascade(user) }()
|
||||||
|
waitForBackupDBCondition(t, r, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=? AND NOT granted)`, key)
|
||||||
|
emptyPorts := []model.ForwardPortBackup{}
|
||||||
|
backup := &model.BackupData{
|
||||||
|
Users: []model.UserBackup{{ID: 100, User: "restored", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||||
|
Forwards: []model.ForwardBackup{{ID: forward, UserID: 100, Name: "restored", ForwardPorts: &emptyPorts}},
|
||||||
|
}
|
||||||
|
imported := make(chan error, 1)
|
||||||
|
runImport := func() {
|
||||||
|
result, err := r.Import(backup, []string{"users", "forwards"})
|
||||||
|
if err == nil && (result.UsersImported != 1 || result.ForwardsImported != 1) {
|
||||||
|
err = fmt.Errorf("wrong retry count: %+v", result)
|
||||||
|
}
|
||||||
|
imported <- err
|
||||||
|
}
|
||||||
|
go runImport()
|
||||||
|
if mode == "busy-bounded" {
|
||||||
|
select {
|
||||||
|
case err := <-imported:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "database is busy") {
|
||||||
|
t.Fatalf("expected bounded busy error, got %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
_ = barrier.Rollback()
|
||||||
|
t.Fatal("import waited while holding partial table locks")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The held child lock makes every attempt fail before writes.
|
||||||
|
// Let several bounded attempts encounter it before release.
|
||||||
|
time.Sleep(75 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err := barrier.Commit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-deleted:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cascade: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("cascade deadlocked")
|
||||||
|
}
|
||||||
|
if mode == "busy-bounded" {
|
||||||
|
go runImport()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-imported:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("import deadlocked")
|
||||||
|
}
|
||||||
|
if id, err := r.CreateUser("after", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id <= 100 {
|
||||||
|
t.Fatalf("create after concurrent cascade: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportRollsBackEarlierSequenceWhenLaterSequenceFails(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
if err := r.DB().Exec(`ALTER SEQUENCE speed_limit_id_seq MAXVALUE 10`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backup := &model.BackupData{
|
||||||
|
Users: []model.UserBackup{{ID: 100, User: "rollback-user", Pwd: "$2-test-fixture", RoleID: 1}},
|
||||||
|
SpeedLimits: []model.SpeedLimitBackup{{ID: 10, Name: "exhausted"}},
|
||||||
|
}
|
||||||
|
// User sequence advances first. The exhausted limit sequence then fails.
|
||||||
|
if _, err := r.Import(backup, []string{"users", "speedLimits"}); err == nil || !strings.Contains(err.Error(), "sequence exhausted") {
|
||||||
|
t.Fatalf("expected sequence exhaustion: %v", err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateUser("after-failure", "hash", 1, 0, 0, 0, 0, 1, 0, 0); err != nil || id != 2 {
|
||||||
|
t.Fatalf("earlier sequence failed to roll back: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
if id, err := r.CreateSpeedLimit("after-failure", 100, 0, 1); err != nil || id != 1 {
|
||||||
|
t.Fatalf("later sequence changed: id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
got, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Users) != 2 || len(got.SpeedLimits) != 1 {
|
||||||
|
t.Fatalf("partial import committed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresBackupImportAPIAllowsImmediateCreate(t *testing.T) {
|
||||||
|
r := ownerNamesRepository(t, "postgres")()
|
||||||
|
const secret = "backup-sequence-contract"
|
||||||
|
router := httpserver.NewRouter(handler.New(r, secret), secret)
|
||||||
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
post := func(path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
request.Header.Set("Authorization", token)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
assertCode(t, response, 0)
|
||||||
|
}
|
||||||
|
post("/api/v1/backup/import", `{"version":"1.0","types":["users","speedLimits"],"users":[{"id":2,"user":"restored","pwd":"$2-test-fixture","roleId":1,"status":1}],"speedLimits":[{"id":1,"name":"restored","speed":100,"status":1}]}`)
|
||||||
|
post("/api/v1/user/create", `{"user":"after-import","pwd":"test-only-password","status":1}`)
|
||||||
|
post("/api/v1/speed-limit/create", `{"name":"after-import","speed":200}`)
|
||||||
|
got, err := r.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Users) != 3 || got.Users[2].ID != 3 || len(got.SpeedLimits) != 2 || got.SpeedLimits[1].ID != 2 {
|
||||||
|
t.Fatalf("API create did not retain restored records: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "relay-auth-contract")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var connector, receiver map[string]interface{}
|
||||||
|
reconnectEvents := make(chan string, 8)
|
||||||
|
watchReconnect := false
|
||||||
|
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "AddChains" || kind == "UpdateChains" {
|
||||||
|
var c map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &c)
|
||||||
|
if nested, ok := c["data"].(map[string]interface{}); ok {
|
||||||
|
c = nested
|
||||||
|
}
|
||||||
|
hops, _ := c["hops"].([]interface{})
|
||||||
|
if len(hops) > 0 {
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "AddService" || kind == "UpdateService" {
|
||||||
|
var services []map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &services)
|
||||||
|
if len(services) > 0 {
|
||||||
|
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
if watchReconnect {
|
||||||
|
p, _ := receiver["password"].(string)
|
||||||
|
select {
|
||||||
|
case reconnectEvents <- p:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
|
||||||
|
defer stopEntry()
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||||
|
defer stopExit()
|
||||||
|
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
mu.Lock()
|
||||||
|
if connector == nil || receiver == nil {
|
||||||
|
t.Fatal("new relay tunnel emitted no endpoint authentication")
|
||||||
|
}
|
||||||
|
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
|
||||||
|
t.Fatal("relay endpoint credentials missing or mismatched")
|
||||||
|
}
|
||||||
|
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
|
||||||
|
t.Fatal("management secret reused for relay")
|
||||||
|
}
|
||||||
|
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
|
||||||
|
listReq.Header.Set("Authorization", token)
|
||||||
|
listRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(listRes, listReq)
|
||||||
|
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||||
|
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
|
||||||
|
}
|
||||||
|
password, _ := receiver["password"].(string)
|
||||||
|
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
|
||||||
|
t.Fatal("tunnel list disclosed relay credential")
|
||||||
|
}
|
||||||
|
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
|
||||||
|
exportReq.Header.Set("Authorization", token)
|
||||||
|
exportRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(exportRes, exportReq)
|
||||||
|
if !bytes.Contains(exportRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||||
|
t.Fatal("configuration export lost required authentication policy")
|
||||||
|
}
|
||||||
|
if bytes.Contains(exportRes.Body.Bytes(), []byte(password)) {
|
||||||
|
t.Fatal("configuration export disclosed private relay password")
|
||||||
|
}
|
||||||
|
originalPassword := password
|
||||||
|
mu.Unlock()
|
||||||
|
var listed struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
|
||||||
|
t.Fatal("missing created tunnel in list")
|
||||||
|
}
|
||||||
|
payload["id"] = listed.Data[0].ID
|
||||||
|
body, _ = json.Marshal(payload)
|
||||||
|
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||||
|
updateReq.Header.Set("Authorization", token)
|
||||||
|
updateRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(updateRes, updateReq)
|
||||||
|
|
||||||
|
assertCode(t, updateRes, 0)
|
||||||
|
mu.Lock()
|
||||||
|
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
|
||||||
|
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
watchReconnect = true
|
||||||
|
mu.Unlock()
|
||||||
|
stopExit()
|
||||||
|
stopReconnected := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||||
|
defer stopReconnected()
|
||||||
|
// Request reconciliation explicitly rather than waiting for the production
|
||||||
|
// reconnect debounce (30 seconds). This crosses the same HTTP/command seam.
|
||||||
|
redeployBody, _ := json.Marshal(map[string]interface{}{"ids": []int64{listed.Data[0].ID}})
|
||||||
|
redeployReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(redeployBody))
|
||||||
|
redeployReq.Header.Set("Authorization", token)
|
||||||
|
redeployRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(redeployRes, redeployReq)
|
||||||
|
assertCode(t, redeployRes, 0)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case password := <-reconnectEvents:
|
||||||
|
if password != originalPassword {
|
||||||
|
t.Fatal("node reconnect lost persisted authentication")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("node reconnect did not reconcile relay service")
|
||||||
|
}
|
||||||
|
// Corrupt private desired state as a fault fixture. Reapply must stop rather
|
||||||
|
// than regenerating a password or emitting an anonymous listener.
|
||||||
|
if err := r.DB().Exec("DELETE FROM relay_endpoint_auth WHERE tunnel_id = ?", listed.Data[0].ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||||
|
missingReq.Header.Set("Authorization", token)
|
||||||
|
missingRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(missingRes, missingReq)
|
||||||
|
var missing struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(missingRes.Body.Bytes(), &missing)
|
||||||
|
if missing.Code == 0 {
|
||||||
|
t.Fatal("missing required credentials silently regenerated or downgraded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFederatedRelayRequiresVersionedAuthAndRejectsDowngrade(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "provider-auth-contract")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
node := insertContractNode(t, r, "provider-auth", "192.0.2.30", "45000-45010", "provider-management", 1)
|
||||||
|
insertPeerShare(t, r, &repo.PeerShare{Name: "auth-share", NodeID: node, Token: "auth-share-token", PortRangeStart: 45000, PortRangeEnd: 45010, IsActive: 1})
|
||||||
|
var mu sync.Mutex
|
||||||
|
var received map[string]interface{}
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, "provider-management", func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
if kind == "UpdateService" {
|
||||||
|
var services []map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &services)
|
||||||
|
if len(services) > 0 {
|
||||||
|
mu.Lock()
|
||||||
|
received, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Authorization", "Bearer auth-share-token")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
reserve := post("/api/v1/federation/runtime/reserve-port", map[string]interface{}{"resourceKey": "auth-contract-endpoint", "protocol": "tls"})
|
||||||
|
var reservation struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
RelayAuthVersion int `json:"relayAuthVersion"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(reserve.Body.Bytes(), &reservation); err != nil || reservation.Code != 0 {
|
||||||
|
t.Fatal("reserve failed")
|
||||||
|
}
|
||||||
|
if reservation.Data.RelayAuthVersion != 1 {
|
||||||
|
t.Fatal("provider did not advertise relay authentication capability")
|
||||||
|
}
|
||||||
|
creds := map[string]interface{}{"version": 1, "username": "test-relay-user", "password": "test-only-relay-password-32-bytes-long"}
|
||||||
|
request := map[string]interface{}{"reservationId": reservation.Data.ReservationID, "resourceKey": "auth-contract-endpoint", "role": "exit", "protocol": "tls", "relayAuth": creds}
|
||||||
|
applied := post("/api/v1/federation/runtime/apply-role", request)
|
||||||
|
if !bytes.Contains(applied.Body.Bytes(), []byte(`"relayAuthVersion":1`)) {
|
||||||
|
t.Fatal("provider did not acknowledge authenticated runtime")
|
||||||
|
}
|
||||||
|
assertCode(t, applied, 0)
|
||||||
|
mu.Lock()
|
||||||
|
actual := received
|
||||||
|
mu.Unlock()
|
||||||
|
if actual == nil || actual["username"] != creds["username"] || actual["password"] != creds["password"] {
|
||||||
|
t.Fatal("provider omitted requested relay authentication")
|
||||||
|
}
|
||||||
|
delete(request, "relayAuth")
|
||||||
|
rejected := post("/api/v1/federation/runtime/apply-role", request)
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rejected.Body.Bytes(), &result)
|
||||||
|
if result.Code == 0 {
|
||||||
|
t.Fatal("provider silently downgraded authenticated runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loss of private persisted state must not be mistaken for legacy mode.
|
||||||
|
if err := r.DB().Exec("UPDATE peer_share_runtime SET relay_auth = '' WHERE resource_key = ?", "auth-contract-endpoint").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request["relayAuth"] = creds
|
||||||
|
missing := post("/api/v1/federation/runtime/apply-role", request)
|
||||||
|
_ = json.Unmarshal(missing.Body.Bytes(), &result)
|
||||||
|
if result.Code == 0 {
|
||||||
|
t.Fatal("provider recreated missing protected state instead of failing closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFederatedTunnelAuthenticatesEveryRelayHop(t *testing.T) {
|
||||||
|
providerSecret := "provider-contract-jwt"
|
||||||
|
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||||
|
providerServer := httptest.NewServer(providerRouter)
|
||||||
|
defer providerServer.Close()
|
||||||
|
|
||||||
|
consumerSecret := "consumer-contract-jwt"
|
||||||
|
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||||
|
|
||||||
|
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate consumer admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||||
|
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||||
|
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||||
|
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "entry-share",
|
||||||
|
NodeID: providerEntryNodeID,
|
||||||
|
Token: "share-entry-token",
|
||||||
|
PortRangeStart: 43000,
|
||||||
|
PortRangeEnd: 43010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "middle-share",
|
||||||
|
NodeID: providerMiddleNodeID,
|
||||||
|
Token: "share-middle-token",
|
||||||
|
PortRangeStart: 44000,
|
||||||
|
PortRangeEnd: 44010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "exit-share",
|
||||||
|
NodeID: providerExitNodeID,
|
||||||
|
Token: "share-exit-token",
|
||||||
|
PortRangeStart: 45000,
|
||||||
|
PortRangeEnd: 45010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||||
|
|
||||||
|
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||||
|
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||||
|
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
services := map[string]string{}
|
||||||
|
strictMiddle, strictExit := false, false
|
||||||
|
connectors := map[string]string{}
|
||||||
|
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "UpdateService" || kind == "AddService" {
|
||||||
|
var items []map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &items)
|
||||||
|
for _, item := range items {
|
||||||
|
handler, _ := item["handler"].(map[string]interface{})
|
||||||
|
if handler["type"] == "relay" {
|
||||||
|
md, _ := handler["metadata"].(map[string]interface{})
|
||||||
|
if handler["chain"] != nil {
|
||||||
|
strictMiddle, _ = md["chain.strict"].(bool)
|
||||||
|
} else {
|
||||||
|
strictExit, _ = md["chain.strict"].(bool)
|
||||||
|
}
|
||||||
|
a, _ := handler["auth"].(map[string]interface{})
|
||||||
|
if a != nil {
|
||||||
|
user, _ := a["username"].(string)
|
||||||
|
pass, _ := a["password"].(string)
|
||||||
|
services[user] = pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "UpdateChains" || kind == "AddChains" {
|
||||||
|
var c map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &c)
|
||||||
|
if d, ok := c["data"].(map[string]interface{}); ok {
|
||||||
|
c = d
|
||||||
|
}
|
||||||
|
hops, _ := c["hops"].([]interface{})
|
||||||
|
for _, hop := range hops {
|
||||||
|
nodes, _ := hop.(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
for _, node := range nodes {
|
||||||
|
a, _ := node.(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
if a != nil {
|
||||||
|
user, _ := a["username"].(string)
|
||||||
|
pass, _ := a["password"].(string)
|
||||||
|
connectors[user] = pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", record)
|
||||||
|
defer stopEntry()
|
||||||
|
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", record)
|
||||||
|
defer stopMiddle()
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", record)
|
||||||
|
defer stopExit()
|
||||||
|
|
||||||
|
createTunnel := func(name string) int64 {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"type": 2,
|
||||||
|
"flow": 99999,
|
||||||
|
"status": 1,
|
||||||
|
"inNodeId": []map[string]interface{}{
|
||||||
|
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
"chainNodes": [][]map[string]interface{}{
|
||||||
|
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||||
|
},
|
||||||
|
"outNodeId": []map[string]interface{}{
|
||||||
|
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal create payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
consumerRouter.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
|
||||||
|
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||||
|
if tunnelID <= 0 {
|
||||||
|
t.Fatalf("invalid tunnel id for %s", name)
|
||||||
|
}
|
||||||
|
return tunnelID
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = createTunnel("dual-panel-middle-exit-1")
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !strictMiddle || strictExit {
|
||||||
|
t.Fatal("middle must require its chain and exit must permit direct dialing")
|
||||||
|
}
|
||||||
|
if len(services) != 2 || len(connectors) != 2 {
|
||||||
|
t.Fatalf("expected two authenticated receivers and matching connectors, got %d and %d", len(services), len(connectors))
|
||||||
|
}
|
||||||
|
for user, pass := range services {
|
||||||
|
if pass == "" || connectors[user] != pass {
|
||||||
|
t.Fatal("federation relay credentials do not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayAuthenticationSurvivesInsertingBeforeExistingMiddle(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "insert-auth-contract")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, _ := auth.GenerateToken(1, "admin", 0, "insert-auth-contract")
|
||||||
|
ids := []int64{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
received := map[string]string{}
|
||||||
|
for i, name := range []string{"entry-a", "middle-b", "middle-c", "exit-d"} {
|
||||||
|
id := insertContractNode(t, r, name, fmt.Sprintf("192.0.2.%d", 40+i), fmt.Sprintf("%d-%d", 46000+i*100, 46010+i*100), name+"-secret", 1)
|
||||||
|
ids = append(ids, id)
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, name+"-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
if kind == "AddService" || kind == "UpdateService" {
|
||||||
|
var items []map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &items)
|
||||||
|
for _, item := range items {
|
||||||
|
handler, _ := item["handler"].(map[string]interface{})
|
||||||
|
if a, ok := handler["auth"].(map[string]interface{}); ok {
|
||||||
|
mu.Lock()
|
||||||
|
received[name], _ = a["password"].(string)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
}
|
||||||
|
node := func(id int64) map[string]interface{} { return map[string]interface{}{"nodeId": id, "protocol": "tls"} }
|
||||||
|
payload := map[string]interface{}{"name": "insert-auth", "type": 2, "inNodeId": []map[string]interface{}{node(ids[0])}, "chainNodes": [][]map[string]interface{}{{node(ids[1])}}, "outNodeId": []map[string]interface{}{node(ids[3])}}
|
||||||
|
post := func(path string) *httptest.ResponseRecorder {
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/create"), 0)
|
||||||
|
mu.Lock()
|
||||||
|
original := received["middle-b"]
|
||||||
|
mu.Unlock()
|
||||||
|
if original == "" {
|
||||||
|
t.Fatal("original middle missing auth")
|
||||||
|
}
|
||||||
|
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "insert-auth")
|
||||||
|
payload["chainNodes"] = [][]map[string]interface{}{{node(ids[2])}, {node(ids[1])}}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/update"), 0)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if received["middle-b"] != original || received["middle-c"] == "" || received["middle-c"] == original {
|
||||||
|
t.Fatal("hop insertion changed existing receiver identity or reused another credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertingDirectTunnelRequiresRelayAuthentication(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "relay-auth-contract")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "auth-entry", "192.0.2.10", "41000-41010", "entry-management-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "auth-exit", "192.0.2.11", "42000-42010", "exit-management-secret", 1)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var connector, receiver map[string]interface{}
|
||||||
|
record := func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "AddChains" || kind == "UpdateChains" {
|
||||||
|
var c map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &c)
|
||||||
|
if nested, ok := c["data"].(map[string]interface{}); ok {
|
||||||
|
c = nested
|
||||||
|
}
|
||||||
|
hops, _ := c["hops"].([]interface{})
|
||||||
|
if len(hops) > 0 {
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
connector, _ = nodes[0].(map[string]interface{})["connector"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "AddService" || kind == "UpdateService" {
|
||||||
|
var services []map[string]interface{}
|
||||||
|
_ = json.Unmarshal(data, &services)
|
||||||
|
if len(services) > 0 {
|
||||||
|
receiver, _ = services[0]["handler"].(map[string]interface{})["auth"].(map[string]interface{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "entry-management-secret", record)
|
||||||
|
defer stopEntry()
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "exit-management-secret", record)
|
||||||
|
defer stopExit()
|
||||||
|
payload := map[string]interface{}{"name": "authenticated-new-tunnel", "type": 1, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
payload["type"] = 2
|
||||||
|
payload["id"] = mustQueryInt64(t, r, "SELECT id FROM tunnel WHERE name = ?", "authenticated-new-tunnel")
|
||||||
|
conversionBody, _ := json.Marshal(payload)
|
||||||
|
conversionReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(conversionBody))
|
||||||
|
conversionReq.Header.Set("Authorization", token)
|
||||||
|
conversionRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(conversionRes, conversionReq)
|
||||||
|
assertCode(t, conversionRes, 0)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if connector == nil || receiver == nil {
|
||||||
|
t.Fatal("new relay tunnel emitted no endpoint authentication")
|
||||||
|
}
|
||||||
|
if connector["username"] == "" || connector["password"] == "" || connector["username"] != receiver["username"] || connector["password"] != receiver["password"] {
|
||||||
|
t.Fatal("relay endpoint credentials missing or mismatched")
|
||||||
|
}
|
||||||
|
if receiver["password"] == "entry-management-secret" || receiver["password"] == "exit-management-secret" {
|
||||||
|
t.Fatal("management secret reused for relay")
|
||||||
|
}
|
||||||
|
listReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString(`{}`))
|
||||||
|
listReq.Header.Set("Authorization", token)
|
||||||
|
listRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(listRes, listReq)
|
||||||
|
if !bytes.Contains(listRes.Body.Bytes(), []byte(`"relayAuthMode":"required"`)) {
|
||||||
|
t.Fatalf("tunnel list does not expose required authentication state: %s", listRes.Body.String())
|
||||||
|
}
|
||||||
|
password, _ := receiver["password"].(string)
|
||||||
|
if bytes.Contains(listRes.Body.Bytes(), []byte(password)) {
|
||||||
|
t.Fatal("tunnel list disclosed relay credential")
|
||||||
|
}
|
||||||
|
originalPassword := password
|
||||||
|
mu.Unlock()
|
||||||
|
var listed struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listRes.Body.Bytes(), &listed); err != nil || len(listed.Data) != 1 {
|
||||||
|
t.Fatal("missing created tunnel in list")
|
||||||
|
}
|
||||||
|
payload["id"] = listed.Data[0].ID
|
||||||
|
body, _ = json.Marshal(payload)
|
||||||
|
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||||
|
updateReq.Header.Set("Authorization", token)
|
||||||
|
updateRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(updateRes, updateReq)
|
||||||
|
|
||||||
|
assertCode(t, updateRes, 0)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if connector == nil || receiver == nil || connector["password"] != originalPassword || receiver["password"] != originalPassword {
|
||||||
|
t.Fatal("topology reapply lost or changed persisted endpoint authentication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupImportCannotSilentlyDowngradeRequiredRelay(t *testing.T) {
|
||||||
|
router, _ := setupContractRouter(t, "backup-relay-auth")
|
||||||
|
token, _ := auth.GenerateToken(1, "admin", 0, "backup-relay-auth")
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewBufferString(`{"version":"1","types":["tunnels"],"tunnels":[{"id":92,"name":"protected-backup","type":2,"protocol":"tls","flow":1,"trafficRatio":1,"createdTime":1000,"status":1,"relayAuthMode":"required"}]}`))
|
||||||
|
request.Header.Set("Authorization", token)
|
||||||
|
result := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(result, request)
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatal("configuration backup restored required relay without its private credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRelayTunnelRefusesLegacyFederationBeforeReservation(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
mutations := 0
|
||||||
|
oldPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/api/v1/federation/connect" {
|
||||||
|
mu.Lock()
|
||||||
|
mutations++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"shareId":1,"shareName":"legacy","nodeId":1,"nodeName":"legacy-exit","serverIp":"192.0.2.80","status":1,"portRangeStart":49000,"portRangeEnd":49010}}`))
|
||||||
|
}))
|
||||||
|
defer oldPeer.Close()
|
||||||
|
router, r := setupContractRouter(t, "legacy-peer-contract")
|
||||||
|
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-peer-contract")
|
||||||
|
entry := insertContractNode(t, r, "legacy-test-entry", "192.0.2.79", "48000-48010", "legacy-test-entry-secret", 1)
|
||||||
|
importRemoteNodeForContract(t, router, token, oldPeer.URL, "legacy-share-token")
|
||||||
|
exit := queryRemoteNodeIDByToken(t, r, "legacy-share-token")
|
||||||
|
payload := map[string]interface{}{"name": "must-not-downgrade", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(b))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(res.Body.Bytes(), &out)
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Fatal("new tunnel silently accepted legacy federation")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if mutations != 0 {
|
||||||
|
t.Fatal("legacy peer was mutated before capability refusal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyRelayStateRemainsExplicitInList(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "legacy-mode-contract")
|
||||||
|
token, _ := auth.GenerateToken(1, "admin", 0, "legacy-mode-contract")
|
||||||
|
if err := r.DB().Exec(`INSERT INTO tunnel(name,traffic_ratio,type,protocol,flow,created_time,updated_time,status,inx) VALUES('legacy-existing',1,2,'tls',1,1000,1000,1,0)`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
if !bytes.Contains(res.Body.Bytes(), []byte(`"relayAuthMode":"legacy"`)) {
|
||||||
|
t.Fatal("legacy relay exposure is hidden from API clients")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemUpgradeStatusRequiresAdminAndReturnsLocalState(t *testing.T) {
|
||||||
|
secret := "upgrade-status-contract-secret"
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PANEL_DEPLOY_DIR", dir)
|
||||||
|
t.Setenv("FLUX_VERSION", "3.0.1-rc10")
|
||||||
|
// A status read with no job must not require Docker or contact GitHub.
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
seedContractUser(t, r, 2, "normal_user", 1, 1)
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, token string
|
||||||
|
want int
|
||||||
|
}{{"anonymous", "", 401}, {"normal user", userToken, 403}, {"admin", adminToken, 0}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("Authorization", tc.token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != tc.want {
|
||||||
|
t.Fatalf("expected code %d, got %d: %s", tc.want, out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
if tc.want == 0 {
|
||||||
|
data, ok := out.Data.(map[string]interface{})
|
||||||
|
if !ok || data["currentVersion"] != "3.0.1-rc10" || data["job"] != nil {
|
||||||
|
t.Fatalf("unexpected idle status: %#v", out.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("idle status should not mutate deployment: entries=%v err=%v", entries, err)
|
||||||
|
}
|
||||||
|
for _, body := range []string{`{"jobId":"../../.env"}`, `{"jobId":`, `{"jobId":123}`} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade/status", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 || out.Data != nil || strings.Contains(out.Msg, dir) || strings.Contains(out.Msg, ".env") {
|
||||||
|
t.Fatalf("invalid request must fail without leaking filesystem detail: %#v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTunnelDeleteWaitsForCreateRuntime(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "create-lifecycle")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "create-lifecycle")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "create-entry", "192.0.2.10", "41000-41010", "create-entry-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "create-exit", "192.0.2.11", "42000-42010", "create-exit-secret", 1)
|
||||||
|
entered, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var once, releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
deleted := make(chan struct{}, 1)
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
if kind == "DeleteChains" {
|
||||||
|
select {
|
||||||
|
case deleted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopEntry()
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "create-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
if kind == "AddService" {
|
||||||
|
once.Do(func() { close(entered); <-release })
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopExit()
|
||||||
|
defer unblock()
|
||||||
|
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
created := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
created <- post("/api/v1/tunnel/create", map[string]interface{}{"name": "create-race", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("create did not reach runtime gate")
|
||||||
|
}
|
||||||
|
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
var list struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||||
|
t.Fatal("committed create not listed")
|
||||||
|
}
|
||||||
|
deleteDone := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() { deleteDone <- post("/api/v1/tunnel/delete", map[string]interface{}{"id": list.Data[0].ID}) }()
|
||||||
|
select {
|
||||||
|
case <-deleted:
|
||||||
|
t.Error("delete removed runtime while create was still applying")
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
for _, done := range []chan *httptest.ResponseRecorder{created, deleteDone} {
|
||||||
|
select {
|
||||||
|
case res := <-done:
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("lifecycle operation stuck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
|
||||||
|
t.Fatal("deleted tunnel remains listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineTunnelCreateReportsPendingRuntime(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "pending-lifecycle")
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "pending-lifecycle")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "pending-entry", "192.0.2.10", "41000-41010", "pending-entry-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "pending-exit", "192.0.2.11", "42000-42010", "pending-exit-secret", 1)
|
||||||
|
payload := map[string]interface{}{"name": "pending-tunnel", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
RuntimeStatus string `json:"runtimeStatus"`
|
||||||
|
TunnelID int64 `json:"tunnelId"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data.RuntimeStatus != "pending" || out.Data.TunnelID <= 0 || out.Msg == "操作成功" {
|
||||||
|
t.Fatalf("offline create must preserve config but explicitly report pending: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["id"] = out.Data.TunnelID
|
||||||
|
raw, _ = json.Marshal(payload)
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
out.Data.RuntimeStatus = ""
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||||
|
t.Fatalf("offline update must report pending: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(map[string]interface{}{"ids": []int64{out.Data.TunnelID}})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
var batch struct {
|
||||||
|
Data struct {
|
||||||
|
SuccessCount int `json:"successCount"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &batch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.Data.SuccessCount != 0 || batch.Data.FailCount != 1 {
|
||||||
|
t.Fatalf("pending redeploy must not count as success: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingTunnelUpdateStillRemovesObsoleteRelay(t *testing.T) {
|
||||||
|
testPendingTunnelRemoval(t, false, false)
|
||||||
|
}
|
||||||
|
func TestFailedObsoleteRelayRemovalPreservesItsChain(t *testing.T) {
|
||||||
|
testPendingTunnelRemoval(t, true, false)
|
||||||
|
}
|
||||||
|
func TestRedesiredRelayKeepsOldPortReservedUntilReplacementAcknowledged(t *testing.T) {
|
||||||
|
testPendingTunnelRemoval(t, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPendingTunnelRemoval(t *testing.T, failCleanup, readd bool) {
|
||||||
|
router, r := setupContractRouter(t, "pending-removal")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "pending-removal")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "removal-entry", "192.0.2.10", "41000-41010", "removal-entry-secret", 1)
|
||||||
|
middle := insertContractNode(t, r, "removal-middle", "192.0.2.20", "42000-42010", "removal-middle-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "removal-exit", "192.0.2.30", "43000-43010", "removal-exit-secret", 1)
|
||||||
|
var entryTimeout atomic.Bool
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-entry-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
if entryTimeout.Load() && (kind == "AddChains" || kind == "UpdateChains") {
|
||||||
|
return true, "等待节点响应超时"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopEntry()
|
||||||
|
var mu sync.Mutex
|
||||||
|
deleted := map[string]bool{}
|
||||||
|
var timeoutReplacement bool
|
||||||
|
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "removal-middle-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "DeleteService" || kind == "DeleteChains" {
|
||||||
|
deleted[kind] = true
|
||||||
|
}
|
||||||
|
if timeoutReplacement && kind == "UpdateService" {
|
||||||
|
return true, "等待节点响应超时"
|
||||||
|
}
|
||||||
|
if failCleanup && kind == "DeleteService" {
|
||||||
|
return true, "cleanup rejected"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopMiddle()
|
||||||
|
stopExit := startMockNodeSession(t, server.URL, "removal-exit-secret")
|
||||||
|
defer stopExit()
|
||||||
|
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"name": "pending-removal", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls"}}}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||||
|
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
var list struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||||
|
t.Fatal("missing tunnel")
|
||||||
|
}
|
||||||
|
if readd {
|
||||||
|
entryTimeout.Store(true)
|
||||||
|
} else {
|
||||||
|
stopEntry()
|
||||||
|
waitNodeStatus(t, r, entry, 0)
|
||||||
|
}
|
||||||
|
payload["id"] = list.Data[0].ID
|
||||||
|
payload["chainNodes"] = [][]map[string]interface{}{}
|
||||||
|
res := post("/api/v1/tunnel/update", payload)
|
||||||
|
var out struct {
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
RuntimeStatus string `json:"runtimeStatus"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||||
|
t.Fatalf("expected pending update: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if failCleanup {
|
||||||
|
if !deleted["DeleteService"] || deleted["DeleteChains"] {
|
||||||
|
t.Fatalf("failed listener deletion must preserve its required chain: %v", deleted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.Msg, "cleanup rejected") {
|
||||||
|
t.Fatalf("cleanup failure hidden: %s", out.Msg)
|
||||||
|
}
|
||||||
|
if readd {
|
||||||
|
deleted = map[string]bool{}
|
||||||
|
timeoutReplacement = true
|
||||||
|
mu.Unlock()
|
||||||
|
payload["chainNodes"] = [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42001}}}
|
||||||
|
changed := post("/api/v1/tunnel/update", payload)
|
||||||
|
if err := json.Unmarshal(changed.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data.RuntimeStatus != "pending" {
|
||||||
|
t.Fatalf("expected pending replacement: %s", changed.Body.String())
|
||||||
|
}
|
||||||
|
contender := map[string]interface{}{"name": "old-port-contender", "type": 2, "inNodeId": payload["inNodeId"], "outNodeId": payload["outNodeId"], "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}}
|
||||||
|
blocked := post("/api/v1/tunnel/create", contender)
|
||||||
|
if err := json.Unmarshal(blocked.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
|
||||||
|
t.Fatalf("unacknowledged old port was not reserved: %s", blocked.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
timeoutReplacement = false
|
||||||
|
mu.Unlock()
|
||||||
|
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||||
|
accepted := post("/api/v1/tunnel/create", contender)
|
||||||
|
if err := json.Unmarshal(accepted.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("acknowledged replacement did not release old port: %s", accepted.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if len(deleted) != 0 {
|
||||||
|
t.Fatalf("delayed cleanup deleted re-desired relay: %v", deleted)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failCleanup = false
|
||||||
|
deleted = map[string]bool{}
|
||||||
|
mu.Unlock()
|
||||||
|
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||||
|
mu.Lock()
|
||||||
|
if !deleted["DeleteService"] || !deleted["DeleteChains"] {
|
||||||
|
t.Fatalf("redeploy lost obsolete cleanup ownership after failure: %v", deleted)
|
||||||
|
}
|
||||||
|
deleted = map[string]bool{}
|
||||||
|
mu.Unlock()
|
||||||
|
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{list.Data[0].ID}})
|
||||||
|
mu.Lock()
|
||||||
|
if len(deleted) != 0 {
|
||||||
|
t.Fatalf("acknowledged cleanup repeated: %v", deleted)
|
||||||
|
}
|
||||||
|
} else if !deleted["DeleteService"] || !deleted["DeleteChains"] {
|
||||||
|
t.Fatalf("pending update left removed relay live: %v", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConcurrentCreatesReserveLocalPortsBeforeFederationAcknowledgment(t *testing.T) {
|
||||||
|
provider, pr := setupContractRouter(t, "port-provider")
|
||||||
|
entered, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var gateOnce, releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
ps := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/v1/federation/runtime/apply-role" {
|
||||||
|
gateOnce.Do(func() { close(entered); <-release })
|
||||||
|
}
|
||||||
|
provider.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer ps.Close()
|
||||||
|
exit := insertContractNode(t, pr, "provider-port-exit", "192.0.2.30", "43000-43010", "provider-port-exit-secret", 1)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
insertPeerShare(t, pr, &repo.PeerShare{Name: "port-share", NodeID: exit, Token: "port-share-token", PortRangeStart: 43000, PortRangeEnd: 43010, IsActive: 1, CreatedTime: now, UpdatedTime: now})
|
||||||
|
stop := startMockNodeSession(t, ps.URL, "provider-port-exit-secret")
|
||||||
|
defer stop()
|
||||||
|
defer unblock()
|
||||||
|
consumer, cr := setupContractRouter(t, "port-consumer")
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "port-consumer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, cr, "local-port-entry", "192.0.2.10", "41000-41010", "local-port-entry-secret", 1)
|
||||||
|
middle := insertContractNode(t, cr, "local-port-middle", "192.0.2.20", "42000", "local-port-middle-secret", 1)
|
||||||
|
importRemoteNodeForContract(t, consumer, token, ps.URL, "port-share-token")
|
||||||
|
remoteExit := queryRemoteNodeIDByToken(t, cr, "port-share-token")
|
||||||
|
create := func(name string) *httptest.ResponseRecorder {
|
||||||
|
payload := map[string]interface{}{"name": name, "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls"}}}, "outNodeId": []map[string]interface{}{{"nodeId": remoteExit, "protocol": "tls"}}}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
consumer.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
first := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() { first <- create("port-owner") }()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("first create did not reach provider gate")
|
||||||
|
}
|
||||||
|
second := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() { second <- create("port-contender") }()
|
||||||
|
secondCompleted := false
|
||||||
|
select {
|
||||||
|
case res := <-second:
|
||||||
|
secondCompleted = true
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 {
|
||||||
|
t.Errorf("second create reused the only reserved local port: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
t.Error("second create did not reject the reserved local port before provider acknowledgment")
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
select {
|
||||||
|
case res := <-first:
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("first create stuck")
|
||||||
|
}
|
||||||
|
// If the regression let the second request reach the gated provider, drain it
|
||||||
|
// before closing test servers; its success still cannot erase the failure above.
|
||||||
|
if !secondCompleted {
|
||||||
|
select {
|
||||||
|
case <-second:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("second create stuck after gate release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
httpserver "go-backend/internal/http"
|
||||||
|
"go-backend/internal/http/handler"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeletedTunnelCleanupSurvivesHandlerReplacement(t *testing.T) {
|
||||||
|
const secret = "deleted-cleanup"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "deleted-entry", "192.0.2.10", "41000-41010", "deleted-entry-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "deleted-exit", "192.0.2.20", "42000", "deleted-exit-secret", 1)
|
||||||
|
stopEntry := startMockNodeSession(t, server.URL, "deleted-entry-secret")
|
||||||
|
defer stopEntry()
|
||||||
|
var rejectDelete atomic.Bool
|
||||||
|
rejectDelete.Store(true)
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
if kind == "DeleteService" && rejectDelete.Load() {
|
||||||
|
return true, "cleanup rejected"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopExit()
|
||||||
|
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"name": "deleted-owner", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 42000}}}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||||
|
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
var list struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||||
|
t.Fatal("missing tunnel")
|
||||||
|
}
|
||||||
|
id := list.Data[0].ID
|
||||||
|
res := post("/api/v1/tunnel/delete", map[string]interface{}{"id": id})
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 || !strings.Contains(out.Msg, "待重试") {
|
||||||
|
t.Fatalf("failed post-delete cleanup not reported: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
listed = post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 0 {
|
||||||
|
t.Fatal("desired tunnel not deleted")
|
||||||
|
}
|
||||||
|
payload["name"] = "deleted-port-contender"
|
||||||
|
res = post("/api/v1/tunnel/create", payload)
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Code == 0 || !strings.Contains(out.Msg, "旧运行时清理") {
|
||||||
|
t.Fatalf("deleted tunnel lost port ownership: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
stopEntry()
|
||||||
|
stopExit()
|
||||||
|
waitNodeStatus(t, r, entry, 0)
|
||||||
|
waitNodeStatus(t, r, exit, 0)
|
||||||
|
// A fresh handler has no old in-memory topology, locks, or cleanup state.
|
||||||
|
router = httpserver.NewRouter(handler.New(r, secret), secret)
|
||||||
|
recoveredServer := httptest.NewServer(router)
|
||||||
|
defer recoveredServer.Close()
|
||||||
|
newEntry := startMockNodeSession(t, recoveredServer.URL, "deleted-entry-secret")
|
||||||
|
defer newEntry()
|
||||||
|
var deleted atomic.Int32
|
||||||
|
newExit := startMockNodeSessionWithCommandRecorder(t, recoveredServer.URL, "deleted-exit-secret", func(kind string, _ json.RawMessage) (bool, string) {
|
||||||
|
if kind == "DeleteService" {
|
||||||
|
deleted.Add(1)
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer newExit()
|
||||||
|
waitNodeStatus(t, r, entry, 1)
|
||||||
|
waitNodeStatus(t, r, exit, 1)
|
||||||
|
assertCode(t, post("/api/v1/tunnel/delete", map[string]interface{}{"id": id}), 0)
|
||||||
|
if deleted.Load() != 1 {
|
||||||
|
t.Fatalf("fresh handler did not recover obsolete listener ownership: %d", deleted.Load())
|
||||||
|
}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||||
|
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||||
|
if deleted.Load() != 1 {
|
||||||
|
t.Fatal("acknowledged cleanup replayed against replacement tunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFederationRedeployPreservesExistingBindings(t *testing.T) {
|
||||||
|
testFederationRedeploy(t, false, false)
|
||||||
|
}
|
||||||
|
func TestFederationFailedRedeployPreservesExistingBindings(t *testing.T) {
|
||||||
|
testFederationRedeploy(t, true, false)
|
||||||
|
}
|
||||||
|
func TestFederationCreateDoesNotHoldDatabaseDuringAcknowledgment(t *testing.T) {
|
||||||
|
testFederationRedeploy(t, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFederationRedeploy(t *testing.T, failRedeploy, gateCreate bool) {
|
||||||
|
providerSecret := "provider-contract-jwt"
|
||||||
|
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||||
|
entered, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var gateOnce, releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if gateCreate && r.URL.Path == "/api/v1/federation/runtime/apply-role" {
|
||||||
|
gateOnce.Do(func() { close(entered); <-release })
|
||||||
|
}
|
||||||
|
providerRouter.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer providerServer.Close()
|
||||||
|
|
||||||
|
consumerSecret := "consumer-contract-jwt"
|
||||||
|
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||||
|
|
||||||
|
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate consumer admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||||
|
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||||
|
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||||
|
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "entry-share",
|
||||||
|
NodeID: providerEntryNodeID,
|
||||||
|
Token: "share-entry-token",
|
||||||
|
PortRangeStart: 43000,
|
||||||
|
PortRangeEnd: 43010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "middle-share",
|
||||||
|
NodeID: providerMiddleNodeID,
|
||||||
|
Token: "share-middle-token",
|
||||||
|
PortRangeStart: 44000,
|
||||||
|
PortRangeEnd: 44010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
_ = insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||||
|
Name: "exit-share",
|
||||||
|
NodeID: providerExitNodeID,
|
||||||
|
Token: "share-exit-token",
|
||||||
|
PortRangeStart: 45000,
|
||||||
|
PortRangeEnd: 45010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||||
|
|
||||||
|
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||||
|
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||||
|
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var destructive []string
|
||||||
|
var armed bool
|
||||||
|
recorder := func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "DeleteService" || kind == "DeleteChains" {
|
||||||
|
destructive = append(destructive, kind)
|
||||||
|
}
|
||||||
|
if armed && failRedeploy && kind == "UpdateService" {
|
||||||
|
return true, "simulated node update failure"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
stopEntry := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-entry-secret", recorder)
|
||||||
|
defer stopEntry()
|
||||||
|
stopMiddle := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-middle-secret", recorder)
|
||||||
|
defer stopMiddle()
|
||||||
|
stopExit := startMockNodeSessionWithCommandRecorder(t, providerServer.URL, "provider-exit-secret", recorder)
|
||||||
|
defer stopExit()
|
||||||
|
|
||||||
|
createTunnel := func(name string) int64 {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"type": 2,
|
||||||
|
"flow": 99999,
|
||||||
|
"status": 1,
|
||||||
|
"inNodeId": []map[string]interface{}{
|
||||||
|
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
"chainNodes": [][]map[string]interface{}{
|
||||||
|
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||||
|
},
|
||||||
|
"outNodeId": []map[string]interface{}{
|
||||||
|
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal create payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
consumerRouter.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
|
||||||
|
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||||
|
if tunnelID <= 0 {
|
||||||
|
t.Fatalf("invalid tunnel id for %s", name)
|
||||||
|
}
|
||||||
|
return tunnelID
|
||||||
|
}
|
||||||
|
|
||||||
|
defer unblock()
|
||||||
|
var firstTunnelID int64
|
||||||
|
if gateCreate {
|
||||||
|
created := make(chan int64, 1)
|
||||||
|
go func() { created <- createTunnel("dual-panel-middle-exit-1") }()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("create did not reach federation gate")
|
||||||
|
}
|
||||||
|
listed := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(listed)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", bytes.NewBufferString("{}"))
|
||||||
|
req.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
consumerRouter.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-listed:
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
t.Error("federation create held database while waiting for acknowledgment")
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
select {
|
||||||
|
case firstTunnelID = <-created:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("create stuck after release")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-listed:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("list stuck after release")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
firstTunnelID = createTunnel("dual-panel-middle-exit-1")
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
destructive = nil
|
||||||
|
armed = true
|
||||||
|
mu.Unlock()
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"ids": []int64{firstTunnelID}})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
consumerRouter.ServeHTTP(res, req)
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
SuccessCount int `json:"successCount"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectedSuccess, expectedFailure := 1, 0
|
||||||
|
if failRedeploy {
|
||||||
|
expectedSuccess, expectedFailure = 0, 1
|
||||||
|
}
|
||||||
|
if out.Code != 0 || out.Data.SuccessCount != expectedSuccess || out.Data.FailCount != expectedFailure {
|
||||||
|
t.Fatalf("redeploy failed: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(destructive) > 0 {
|
||||||
|
t.Fatalf("redeploy destroyed existing federation runtime: %v", destructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
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, 'issue281_transition_user', '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(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||||
|
|
||||||
|
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return mustLastInsertID(t, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||||
|
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||||
|
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||||
|
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, entryA).Error; err != nil {
|
||||||
|
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, exitNodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert exit chain_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(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||||
|
`, tunnelID).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, tunnelID, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This mock records control-plane commands only; it does not forward traffic.
|
||||||
|
type command struct {
|
||||||
|
node, kind string
|
||||||
|
data json.RawMessage
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var commands []command
|
||||||
|
for _, n := range []struct {
|
||||||
|
name, secret string
|
||||||
|
id int64
|
||||||
|
}{
|
||||||
|
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||||
|
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||||
|
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||||
|
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||||
|
} {
|
||||||
|
name := n.name
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
waitNodeStatus(t, r, n.id, 1)
|
||||||
|
}
|
||||||
|
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
node := func(id int64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||||
|
}
|
||||||
|
update := func(hops [][]map[string]interface{}) {
|
||||||
|
mu.Lock()
|
||||||
|
commands = nil
|
||||||
|
mu.Unlock()
|
||||||
|
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||||
|
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||||
|
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
}
|
||||||
|
check := func(edges map[string]int64) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||||
|
got := map[string]bool{}
|
||||||
|
services := map[string]bool{}
|
||||||
|
lastChains := map[string]int{}
|
||||||
|
lastServices := map[string]int{}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
lastChains[c.node] = i
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||||
|
lastServices[c.node] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "DeleteChains" {
|
||||||
|
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
if lastChains[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||||
|
raw = nested
|
||||||
|
}
|
||||||
|
target, ok := edges[c.node]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected chain owner %s", c.node)
|
||||||
|
}
|
||||||
|
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||||
|
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||||
|
hops := raw["hops"].([]interface{})
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
addr := nodes[0].(map[string]interface{})["addr"]
|
||||||
|
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||||
|
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
got[c.node] = true
|
||||||
|
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||||
|
if lastServices[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ss []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range ss {
|
||||||
|
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := s["handler"].(map[string]interface{})
|
||||||
|
if c.node == "B" || c.node == "C" {
|
||||||
|
if h["chain"] != expectedChain {
|
||||||
|
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.node == "D" && h["chain"] != nil {
|
||||||
|
t.Fatalf("exit has unexpected chain")
|
||||||
|
}
|
||||||
|
services[c.node] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner := range edges {
|
||||||
|
if !got[owner] {
|
||||||
|
t.Errorf("no chain update for %s", owner)
|
||||||
|
}
|
||||||
|
if owner != "A" && !services[owner] {
|
||||||
|
t.Errorf("no relay service for %s", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !services["D"] {
|
||||||
|
t.Error("no exit relay service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update([][]map[string]interface{}{{node(entryB)}})
|
||||||
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||||
|
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
||||||
|
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
|
||||||
|
// A remains the entry and the user's forward remains bound there.
|
||||||
|
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
||||||
|
if after != entryA {
|
||||||
|
t.Fatalf("forward entry changed: %d", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
|
||||||
|
t.Skip("Pre-repair characterization; preserved in plans/evidence/070-tunnel-insert-middle-hop-investigation.go.txt. Replaced by TestTunnelRedeployPreservesLiveRoutes and TestTunnelEditWaitsForRedeploy.")
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
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, 'issue281_transition_user', '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(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||||
|
|
||||||
|
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return mustLastInsertID(t, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||||
|
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||||
|
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||||
|
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, entryA).Error; err != nil {
|
||||||
|
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, exitNodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert exit chain_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(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||||
|
`, tunnelID).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, tunnelID, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This mock records control-plane commands only; it does not forward traffic.
|
||||||
|
type command struct {
|
||||||
|
node, kind string
|
||||||
|
data json.RawMessage
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var commands []command
|
||||||
|
armed := false
|
||||||
|
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
|
||||||
|
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
|
||||||
|
var gateOnce, releaseOnce sync.Once
|
||||||
|
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
for _, n := range []struct {
|
||||||
|
name, secret string
|
||||||
|
id int64
|
||||||
|
}{
|
||||||
|
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||||
|
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||||
|
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||||
|
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||||
|
} {
|
||||||
|
name := n.name
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
active := armed
|
||||||
|
shouldGate := active && name == "B" && kind == "DeleteService"
|
||||||
|
if active && name == "A" && kind == "UpdateChains" {
|
||||||
|
select {
|
||||||
|
case editReachedA <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if shouldGate {
|
||||||
|
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "DeleteChains" {
|
||||||
|
chainPresent[name] = false
|
||||||
|
}
|
||||||
|
if kind == "AddChains" || kind == "UpdateChains" {
|
||||||
|
chainPresent[name] = true
|
||||||
|
}
|
||||||
|
if kind == "DeleteService" {
|
||||||
|
servicePresent[name] = false
|
||||||
|
}
|
||||||
|
if kind == "AddService" || kind == "UpdateService" {
|
||||||
|
servicePresent[name] = true
|
||||||
|
}
|
||||||
|
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
waitNodeStatus(t, r, n.id, 1)
|
||||||
|
}
|
||||||
|
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
node := func(id int64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
||||||
|
}
|
||||||
|
update := func(hops [][]map[string]interface{}) {
|
||||||
|
mu.Lock()
|
||||||
|
commands = nil
|
||||||
|
mu.Unlock()
|
||||||
|
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||||
|
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||||
|
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
}
|
||||||
|
check := func(edges map[string]int64) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||||
|
got := map[string]bool{}
|
||||||
|
services := map[string]bool{}
|
||||||
|
lastChains := map[string]int{}
|
||||||
|
lastServices := map[string]int{}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
lastChains[c.node] = i
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||||
|
lastServices[c.node] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "DeleteChains" {
|
||||||
|
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
if lastChains[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||||
|
raw = nested
|
||||||
|
}
|
||||||
|
target, ok := edges[c.node]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected chain owner %s", c.node)
|
||||||
|
}
|
||||||
|
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
||||||
|
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||||
|
hops := raw["hops"].([]interface{})
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
addr := nodes[0].(map[string]interface{})["addr"]
|
||||||
|
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||||
|
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
got[c.node] = true
|
||||||
|
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||||
|
if lastServices[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ss []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range ss {
|
||||||
|
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := s["handler"].(map[string]interface{})
|
||||||
|
if c.node == "B" || c.node == "C" {
|
||||||
|
if h["chain"] != expectedChain {
|
||||||
|
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.node == "D" && h["chain"] != nil {
|
||||||
|
t.Fatalf("exit has unexpected chain")
|
||||||
|
}
|
||||||
|
services[c.node] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner := range edges {
|
||||||
|
if !got[owner] {
|
||||||
|
t.Errorf("no chain update for %s", owner)
|
||||||
|
}
|
||||||
|
if owner != "A" && !services[owner] {
|
||||||
|
t.Errorf("no relay service for %s", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !services["D"] {
|
||||||
|
t.Error("no exit relay service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update([][]map[string]interface{}{{node(entryB)}})
|
||||||
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
armed = true
|
||||||
|
mu.Unlock()
|
||||||
|
redeployDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(redeployDone)
|
||||||
|
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
t.Logf("redeploy response: %s", res.Body.String())
|
||||||
|
}()
|
||||||
|
await := func(ch <-chan struct{}, label string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
release()
|
||||||
|
t.Fatalf("timeout waiting for %s", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await(atDeleteService, "B DeleteService gate")
|
||||||
|
editDone := make(chan struct{})
|
||||||
|
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
|
||||||
|
await(editReachedA, "edit UpdateChains on A")
|
||||||
|
mu.Lock()
|
||||||
|
missing, live := !chainPresent["B"], servicePresent["B"]
|
||||||
|
mu.Unlock()
|
||||||
|
if !missing || !live {
|
||||||
|
release()
|
||||||
|
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
|
||||||
|
}
|
||||||
|
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
|
||||||
|
release()
|
||||||
|
await(editDone, "edit completion")
|
||||||
|
await(redeployDone, "redeploy completion")
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTunnelRedeployPreservesLiveRoutes(t *testing.T) {
|
||||||
|
testTunnelLifecycle(t, false, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelEditWaitsForRedeploy(t *testing.T) {
|
||||||
|
testTunnelLifecycle(t, true, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelFailedRedeployPreservesLiveRoutes(t *testing.T) {
|
||||||
|
testTunnelLifecycle(t, false, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelDeleteWaitsForRedeploy(t *testing.T) { testTunnelLifecycle(t, true, false, true) }
|
||||||
|
|
||||||
|
func testTunnelLifecycle(t *testing.T, concurrentEdit, failRedeploy, concurrentDelete bool) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
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, 'issue281_transition_user', '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(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
||||||
|
|
||||||
|
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO node(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return mustLastInsertID(t, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
||||||
|
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
||||||
|
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
||||||
|
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, entryA).Error; err != nil {
|
||||||
|
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, exitNodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert exit chain_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(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||||
|
`, tunnelID).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, tunnelID, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
||||||
|
|
||||||
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This mock records control-plane commands only; it does not forward traffic.
|
||||||
|
type command struct {
|
||||||
|
node, kind string
|
||||||
|
data json.RawMessage
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var commands []command
|
||||||
|
var armed, gateClaimed bool
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var gateOnce, releaseOnce sync.Once
|
||||||
|
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||||
|
defer unblock()
|
||||||
|
editApplied := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
for _, n := range []struct {
|
||||||
|
name, secret string
|
||||||
|
id int64
|
||||||
|
}{
|
||||||
|
{"A", "issue281-transition-entry-a-secret", entryA},
|
||||||
|
{"B", "issue281-transition-entry-b-secret", entryB},
|
||||||
|
{"C", "issue281-transition-entry-c-secret", entryC},
|
||||||
|
{"D", "issue281-transition-exit-secret", exitNodeID},
|
||||||
|
} {
|
||||||
|
name := n.name
|
||||||
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
gate := armed && !gateClaimed && name == "D" && kind == "UpdateService"
|
||||||
|
if gate {
|
||||||
|
gateClaimed = true
|
||||||
|
}
|
||||||
|
if armed && ((name == "C" && (kind == "UpdateChains" || kind == "AddChains")) || (concurrentDelete && kind == "DeleteChains")) {
|
||||||
|
select {
|
||||||
|
case editApplied <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if gate {
|
||||||
|
gateOnce.Do(func() { close(entered); <-release })
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
||||||
|
if armed && failRedeploy && name == "B" && kind == "UpdateService" {
|
||||||
|
return true, "bind: address already in use"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
waitNodeStatus(t, r, n.id, 1)
|
||||||
|
}
|
||||||
|
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
node := func(id int64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round", "port": 54002}
|
||||||
|
}
|
||||||
|
update := func(hops [][]map[string]interface{}) {
|
||||||
|
mu.Lock()
|
||||||
|
commands = nil
|
||||||
|
mu.Unlock()
|
||||||
|
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
||||||
|
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
||||||
|
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
}
|
||||||
|
check := func(edges map[string]int64) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
||||||
|
got := map[string]bool{}
|
||||||
|
services := map[string]bool{}
|
||||||
|
lastChains := map[string]int{}
|
||||||
|
lastServices := map[string]int{}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
lastChains[c.node] = i
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateService" || c.kind == "AddService" {
|
||||||
|
lastServices[c.node] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range commands {
|
||||||
|
if c.kind == "DeleteChains" {
|
||||||
|
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
||||||
|
if lastChains[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||||
|
raw = nested
|
||||||
|
}
|
||||||
|
target, ok := edges[c.node]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected chain owner %s", c.node)
|
||||||
|
}
|
||||||
|
port := 54002
|
||||||
|
if target == exitNodeID {
|
||||||
|
port = 57001
|
||||||
|
}
|
||||||
|
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
||||||
|
hops := raw["hops"].([]interface{})
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
addr := nodes[0].(map[string]interface{})["addr"]
|
||||||
|
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
||||||
|
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
got[c.node] = true
|
||||||
|
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
||||||
|
if lastServices[c.node] != i {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ss []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &ss); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range ss {
|
||||||
|
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := s["handler"].(map[string]interface{})
|
||||||
|
if c.node == "B" || c.node == "C" {
|
||||||
|
if h["chain"] != expectedChain {
|
||||||
|
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.node == "D" && h["chain"] != nil {
|
||||||
|
t.Fatalf("exit has unexpected chain")
|
||||||
|
}
|
||||||
|
services[c.node] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner := range edges {
|
||||||
|
if !got[owner] {
|
||||||
|
t.Errorf("no chain update for %s", owner)
|
||||||
|
}
|
||||||
|
if owner != "A" && !services[owner] {
|
||||||
|
t.Errorf("no relay service for %s", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !services["D"] {
|
||||||
|
t.Error("no exit relay service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update([][]map[string]interface{}{{node(entryB)}})
|
||||||
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||||
|
mu.Lock()
|
||||||
|
commands = nil
|
||||||
|
mu.Unlock()
|
||||||
|
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
if !concurrentEdit {
|
||||||
|
mu.Lock()
|
||||||
|
armed = failRedeploy
|
||||||
|
mu.Unlock()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
if failRedeploy {
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
SuccessCount int `json:"successCount"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Data.FailCount != 1 || out.Data.SuccessCount != 0 {
|
||||||
|
t.Fatalf("expected redeploy failure: %s", res.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, c := range commands {
|
||||||
|
if c.kind == "DeleteService" || c.kind == "DeleteChains" {
|
||||||
|
t.Errorf("failed redeploy removed existing runtime: %s on %s", c.kind, c.node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
armed = true
|
||||||
|
mu.Unlock()
|
||||||
|
redeployDone := make(chan struct{})
|
||||||
|
go func() { defer close(redeployDone); router.ServeHTTP(res, req) }()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("redeploy did not reach command gate")
|
||||||
|
}
|
||||||
|
editDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(editDone)
|
||||||
|
if concurrentDelete {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"id": tunnelID})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
} else {
|
||||||
|
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-editApplied:
|
||||||
|
t.Error("concurrent mutation changed runtime before redeploy finished")
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
for _, done := range []chan struct{}{redeployDone, editDone} {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("lifecycle operation did not complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
if !concurrentDelete {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
latest := map[string]string{}
|
||||||
|
for _, c := range commands {
|
||||||
|
if c.kind != "UpdateChains" && c.kind != "AddChains" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
||||||
|
raw = nested
|
||||||
|
}
|
||||||
|
hops := raw["hops"].([]interface{})
|
||||||
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
||||||
|
latest[c.node] = nodes[0].(map[string]interface{})["addr"].(string)
|
||||||
|
}
|
||||||
|
if latest["B"] != "10.52.0.3:54002" {
|
||||||
|
t.Fatalf("stale redeploy overwrote edit: B targets %s", latest["B"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamContract(t *testing.T) {
|
||||||
|
const secret = "contract-jwt-secret"
|
||||||
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999998,999999]}`))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
if !strings.HasPrefix(res.Header().Get("Content-Type"), "application/x-ndjson") {
|
||||||
|
t.Fatalf("expected streamed progress, got %s: %s", res.Header().Get("Content-Type"), res.Body.String())
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(res.Body)
|
||||||
|
for _, want := range []string{"start", "processing", "item", "processing", "item", "done"} {
|
||||||
|
var event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&event); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event.Type != want {
|
||||||
|
t.Fatalf("got %s, want %s", event.Type, want)
|
||||||
|
}
|
||||||
|
if want == "item" {
|
||||||
|
var data struct{ Result map[string]interface{} }
|
||||||
|
if err := json.Unmarshal(event.Data, &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := data.Result["name"].(string); !ok {
|
||||||
|
t.Fatalf("item name must be a string, including missing tunnels: %s", event.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want == "done" {
|
||||||
|
var data struct {
|
||||||
|
Progress struct{ Total, Completed, Success, Failed int }
|
||||||
|
Result struct {
|
||||||
|
SuccessCount, FailCount int
|
||||||
|
Failures []struct {
|
||||||
|
ID int64
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Data, &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if data.Progress.Total != 2 || data.Progress.Completed != 2 || data.Progress.Failed != 2 || data.Progress.Success != 0 || data.Result.FailCount != 2 || len(data.Result.Failures) != 2 {
|
||||||
|
t.Fatalf("incorrect completion: %+v", data)
|
||||||
|
}
|
||||||
|
if data.Result.Failures[0].Reason == "" {
|
||||||
|
t.Fatal("missing failure detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !res.Flushed {
|
||||||
|
t.Fatal("stream did not flush")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelRedeployStreamAuthorizationContract(t *testing.T) {
|
||||||
|
const secret = "contract-jwt-secret"
|
||||||
|
router, repository := setupContractRouter(t, secret)
|
||||||
|
seedLegacyUser(t, repository, 9111, "stream-normal-user", "password")
|
||||||
|
token, err := auth.GenerateToken(9111, "stream-normal-user", 1, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, token string
|
||||||
|
code int
|
||||||
|
}{{"anonymous", "", 401}, {"non-admin", token, 403}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(`{"ids":[999999]}`))
|
||||||
|
req.Header.Set("Authorization", tc.token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, tc.code)
|
||||||
|
if strings.Contains(res.Body.String(), `"type":"start"`) {
|
||||||
|
t.Fatal("unauthorized request started stream")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMiddleToExitKeepsChainUntilReceiverReplacementAcknowledged(t *testing.T) {
|
||||||
|
for _, port := range []int{42000, 42001} {
|
||||||
|
for _, failure := range []string{"等待节点响应超时", "replacement rejected"} {
|
||||||
|
t.Run(fmt.Sprintf("port=%d/failure=%s", port, failure), func(t *testing.T) {
|
||||||
|
router, r := setupContractRouter(t, "role-transition")
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
token, err := auth.GenerateToken(1, "admin", 0, "role-transition")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := insertContractNode(t, r, "role-entry", "192.0.2.10", "41000-41010", "role-entry-secret", 1)
|
||||||
|
middle := insertContractNode(t, r, "role-middle", "192.0.2.20", "42000-42010", "role-middle-secret", 1)
|
||||||
|
exit := insertContractNode(t, r, "role-exit", "192.0.2.30", "43000-43010", "role-exit-secret", 1)
|
||||||
|
stopEntry := startMockNodeSession(t, server.URL, "role-entry-secret")
|
||||||
|
defer stopEntry()
|
||||||
|
var mu sync.Mutex
|
||||||
|
failReplacement := false
|
||||||
|
replacementAccepted := false
|
||||||
|
unsafeDeletion := false
|
||||||
|
chainDeletes := 0
|
||||||
|
stopMiddle := startMockNodeSessionWithCommandRecorder(t, server.URL, "role-middle-secret", func(kind string, data json.RawMessage) (bool, string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if kind == "UpdateService" {
|
||||||
|
if failReplacement {
|
||||||
|
return true, failure
|
||||||
|
}
|
||||||
|
var services []struct {
|
||||||
|
Handler struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
} `json:"handler"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &services); err == nil && len(services) > 0 && services[0].Handler.Chain == "" {
|
||||||
|
replacementAccepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "DeleteChains" {
|
||||||
|
chainDeletes++
|
||||||
|
if !replacementAccepted {
|
||||||
|
unsafeDeletion = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
})
|
||||||
|
defer stopMiddle()
|
||||||
|
stopExit := startMockNodeSession(t, server.URL, "role-exit-secret")
|
||||||
|
defer stopExit()
|
||||||
|
post := func(path string, payload interface{}) *httptest.ResponseRecorder {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"name": "role-transition", "type": 2, "inNodeId": []map[string]interface{}{{"nodeId": entry, "protocol": "tls"}}, "chainNodes": [][]map[string]interface{}{{{"nodeId": middle, "protocol": "tls", "port": 42000}}}, "outNodeId": []map[string]interface{}{{"nodeId": exit, "protocol": "tls", "port": 43000}}}
|
||||||
|
assertCode(t, post("/api/v1/tunnel/create", payload), 0)
|
||||||
|
listed := post("/api/v1/tunnel/list", map[string]interface{}{})
|
||||||
|
var list struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listed.Body.Bytes(), &list); err != nil || len(list.Data) != 1 {
|
||||||
|
t.Fatal("missing tunnel")
|
||||||
|
}
|
||||||
|
id := list.Data[0].ID
|
||||||
|
mu.Lock()
|
||||||
|
failReplacement = true
|
||||||
|
mu.Unlock()
|
||||||
|
payload["id"] = id
|
||||||
|
payload["chainNodes"] = [][]map[string]interface{}{}
|
||||||
|
payload["outNodeId"] = []map[string]interface{}{{"nodeId": middle, "protocol": "tls", "port": port}}
|
||||||
|
post("/api/v1/tunnel/update", payload)
|
||||||
|
post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||||
|
mu.Lock()
|
||||||
|
if chainDeletes != 0 || unsafeDeletion {
|
||||||
|
t.Errorf("old middle chain removed before receiver replacement ACK: deletes=%d unsafe=%v", chainDeletes, unsafeDeletion)
|
||||||
|
}
|
||||||
|
failReplacement = false
|
||||||
|
mu.Unlock()
|
||||||
|
recovered := post("/api/v1/tunnel/batch-redeploy", map[string]interface{}{"ids": []int64{id}})
|
||||||
|
var result struct {
|
||||||
|
Data struct {
|
||||||
|
SuccessCount int `json:"successCount"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(recovered.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Data.SuccessCount != 1 || result.Data.FailCount != 0 {
|
||||||
|
t.Fatalf("replacement retry failed: %s", recovered.Body.String())
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !replacementAccepted || chainDeletes != 1 || unsafeDeletion {
|
||||||
|
t.Fatalf("obsolete chain must be removed exactly after successful exit replacement: accepted=%v deletes=%d unsafe=%v", replacementAccepted, chainDeletes, unsafeDeletion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManualUserDisableRevokesLoginTokenAndForwardAccess(t *testing.T) {
|
||||||
|
const secret = "contract-jwt-secret"
|
||||||
|
router, r := setupContractRouter(t, secret)
|
||||||
|
seedContractUser(t, r, 2, "disable-contract", 1, 1)
|
||||||
|
for _, value := range []interface{}{
|
||||||
|
&model.Tunnel{ID: 1, Name: "disable-contract", Type: 1, Status: 1, Flow: 1},
|
||||||
|
&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
|
||||||
|
&model.Forward{ID: 20, UserID: 2, UserName: "disable-contract", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 0},
|
||||||
|
} {
|
||||||
|
if err := r.DB().Create(value).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userToken, err := auth.GenerateToken(2, "disable-contract", 1, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := func(path, token, body string) response.R {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if out := request("/api/v1/forward/list", userToken, `{}`); out.Code != 0 {
|
||||||
|
t.Fatalf("token invalid before disable: %s", out.Msg)
|
||||||
|
}
|
||||||
|
if out := request("/api/v1/user/update", adminToken, `{"id":2,"user":"disable-contract","status":0}`); out.Code != 0 {
|
||||||
|
t.Fatalf("disable user: %s", out.Msg)
|
||||||
|
}
|
||||||
|
for _, check := range []struct{ path, body string }{
|
||||||
|
{"/api/v1/forward/list", `{}`},
|
||||||
|
{"/api/v1/forward/create", `{"tunnelId":1,"name":"bypass","remoteAddr":"127.0.0.1:80"}`},
|
||||||
|
{"/api/v1/forward/resume", `{"id":20}`},
|
||||||
|
{"/api/v1/forward/batch-resume", `{"ids":[20]}`},
|
||||||
|
} {
|
||||||
|
if out := request(check.path, userToken, check.body); out.Code != 401 {
|
||||||
|
t.Errorf("disabled user's old token accepted at %s: %+v", check.path, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out := request("/api/v1/user/login", "", `{"username":"disable-contract","password":"contract-pass","captchaId":""}`); out.Code == 0 || !strings.Contains(out.Msg, "停用") {
|
||||||
|
t.Errorf("disabled account login: %+v", out)
|
||||||
|
}
|
||||||
|
if out := request("/api/v1/forward/resume", adminToken, `{"id":20}`); out.Code == 0 || !strings.Contains(out.Msg, "禁用") {
|
||||||
|
t.Errorf("admin bypassed disabled owner's policy: %+v", out)
|
||||||
|
}
|
||||||
|
out := request("/api/v1/tunnel/user/list", adminToken, `{"userId":2}`)
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("list permissions: %s", out.Msg)
|
||||||
|
}
|
||||||
|
items, ok := out.Data.([]interface{})
|
||||||
|
if !ok || len(items) != 1 {
|
||||||
|
t.Fatalf("permission list: %#v", out.Data)
|
||||||
|
}
|
||||||
|
permission, ok := items[0].(map[string]interface{})
|
||||||
|
if !ok || permission["status"] != float64(0) {
|
||||||
|
t.Errorf("permission still displayed as enabled: %#v", items[0])
|
||||||
|
}
|
||||||
|
if got := mustQueryInt(t, r, "SELECT status FROM forward WHERE id = 20"); got != 0 {
|
||||||
|
t.Errorf("disabled forward resumed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
module github.com/go-gost/gost
|
module github.com/go-gost/gost
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-gost/core v0.3.1
|
github.com/go-gost/core v0.3.1
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package chain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
corechain "github.com/go-gost/core/chain"
|
||||||
|
"github.com/go-gost/core/connector"
|
||||||
|
"github.com/go-gost/core/dialer"
|
||||||
|
"github.com/go-gost/core/handler"
|
||||||
|
"github.com/go-gost/core/listener"
|
||||||
|
corelogger "github.com/go-gost/core/logger"
|
||||||
|
xauth "github.com/go-gost/x/auth"
|
||||||
|
xchain "github.com/go-gost/x/chain"
|
||||||
|
relayconnector "github.com/go-gost/x/connector/relay"
|
||||||
|
kcpdialer "github.com/go-gost/x/dialer/kcp"
|
||||||
|
relayhandler "github.com/go-gost/x/handler/relay"
|
||||||
|
netdialer "github.com/go-gost/x/internal/net/dialer"
|
||||||
|
kcplistener "github.com/go-gost/x/listener/kcp"
|
||||||
|
tcplistener "github.com/go-gost/x/listener/tcp"
|
||||||
|
tlslistener "github.com/go-gost/x/listener/tls"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
"github.com/go-gost/x/metadata"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local-only matrix: native GOST listeners, relay handler and connectors. The
|
||||||
|
// credential below is synthetic test data, never used outside loopback sockets.
|
||||||
|
func TestRelayAuthTransportMatrixInvestigation(t *testing.T) {
|
||||||
|
log := xlogger.Nop()
|
||||||
|
corelogger.SetDefault(log)
|
||||||
|
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
cert := certServer.TLS.Certificates[0]
|
||||||
|
leaf := certServer.Certificate()
|
||||||
|
certServer.Close()
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(leaf)
|
||||||
|
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer tcpTarget.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := tcpTarget.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer udpTarget.Close()
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, a, e := udpTarget.ReadFrom(buf)
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpTarget.WriteTo(buf[:n], a)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, transport := range []string{"tcp", "tls", "kcp"} {
|
||||||
|
for _, network := range []string{"tcp", "udp"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
auth bool
|
||||||
|
credentials *url.Userinfo
|
||||||
|
success bool
|
||||||
|
}{
|
||||||
|
{"legacy-no-credentials", false, nil, true},
|
||||||
|
{"legacy-valid-credentials", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||||
|
{"secured-no-credentials", true, nil, false},
|
||||||
|
{"secured-wrong-credentials", true, url.UserPassword("test-peer", "incorrect"), false},
|
||||||
|
{"secured-valid-credentials", true, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||||
|
} {
|
||||||
|
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
|
||||||
|
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
|
||||||
|
var ln listener.Listener
|
||||||
|
switch transport {
|
||||||
|
case "tls":
|
||||||
|
ln = tlslistener.NewListener(opts...)
|
||||||
|
case "kcp":
|
||||||
|
ln = kcplistener.NewListener(opts...)
|
||||||
|
default:
|
||||||
|
ln = tcplistener.NewListener(opts...)
|
||||||
|
}
|
||||||
|
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
|
||||||
|
if e := ln.Init(md); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
hopts := []handler.Option{handler.RouterOption(xchain.NewRouter(corechain.LoggerRouterOption(log))), handler.LoggerOption(log), handler.ServiceOption("auth-investigation")}
|
||||||
|
if test.auth {
|
||||||
|
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
|
||||||
|
if closer, ok := au.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
hopts = append(hopts, handler.AutherOption(au))
|
||||||
|
}
|
||||||
|
h := relayhandler.NewHandler(hopts...)
|
||||||
|
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if closer, ok := h.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
handled := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(handled)
|
||||||
|
c, e := ln.Accept()
|
||||||
|
if e == nil {
|
||||||
|
h.Handle(context.Background(), c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var raw net.Conn
|
||||||
|
var err error
|
||||||
|
switch transport {
|
||||||
|
case "tls":
|
||||||
|
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
|
||||||
|
case "kcp":
|
||||||
|
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
|
||||||
|
if e := d.Init(md); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if closer, ok := d.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
|
||||||
|
default:
|
||||||
|
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
copts := []connector.Option{connector.LoggerOption(log)}
|
||||||
|
if test.credentials != nil {
|
||||||
|
copts = append(copts, connector.AuthOption(test.credentials))
|
||||||
|
}
|
||||||
|
client := relayconnector.NewConnector(copts...)
|
||||||
|
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
target := tcpTarget.Addr().String()
|
||||||
|
if network == "udp" {
|
||||||
|
target = udpTarget.LocalAddr().String()
|
||||||
|
}
|
||||||
|
conn, err := client.Connect(context.Background(), raw, network, target)
|
||||||
|
if !test.success {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unauthorized client was accepted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||||
|
t.Fatalf("expected authentication refusal, got %v", err)
|
||||||
|
}
|
||||||
|
raw.Close()
|
||||||
|
<-handled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = conn.Write([]byte("probe")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := make([]byte, 64)
|
||||||
|
n, err := conn.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(b[:n]) != "probe" {
|
||||||
|
t.Fatalf("unexpected response %q", b[:n])
|
||||||
|
}
|
||||||
|
raw.Close()
|
||||||
|
select {
|
||||||
|
case <-handled:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("handler did not close")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package chain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
corechain "github.com/go-gost/core/chain"
|
||||||
|
"github.com/go-gost/core/connector"
|
||||||
|
"github.com/go-gost/core/handler"
|
||||||
|
corelogger "github.com/go-gost/core/logger"
|
||||||
|
xchain "github.com/go-gost/x/chain"
|
||||||
|
"github.com/go-gost/x/config"
|
||||||
|
parser "github.com/go-gost/x/config/parsing/chain"
|
||||||
|
relayconnector "github.com/go-gost/x/connector/relay"
|
||||||
|
_ "github.com/go-gost/x/dialer/tcp"
|
||||||
|
relayhandler "github.com/go-gost/x/handler/relay"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
"github.com/go-gost/x/metadata"
|
||||||
|
"github.com/go-gost/x/registry"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This test documents existing fail-open behavior, not desired secure behavior.
|
||||||
|
// Every socket is bound to 127.0.0.1, no production config or external target.
|
||||||
|
func TestRelayMissingChainDataPathInvestigation(t *testing.T) {
|
||||||
|
log := xlogger.Nop()
|
||||||
|
originalLogger := corelogger.Default()
|
||||||
|
corelogger.SetDefault(log)
|
||||||
|
if originalLogger != nil {
|
||||||
|
defer corelogger.SetDefault(originalLogger)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := target.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
type relay struct {
|
||||||
|
addr string
|
||||||
|
count *atomic.Int32
|
||||||
|
}
|
||||||
|
start := func(name, chainName string) relay {
|
||||||
|
ln, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
|
||||||
|
h := relayhandler.NewHandler(handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name))
|
||||||
|
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if closer, ok := h.(io.Closer); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
count := &atomic.Int32{}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := ln.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count.Add(1)
|
||||||
|
go h.Handle(ctx, c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return relay{ln.Addr().String(), count}
|
||||||
|
}
|
||||||
|
replace := func(name, addr string) {
|
||||||
|
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: addr, Connector: &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = registry.ReplaceChain(name, c); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"investigation-A", "investigation-B", "investigation-C"} {
|
||||||
|
name := name
|
||||||
|
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
|
||||||
|
}
|
||||||
|
d := start("D", "")
|
||||||
|
c := start("C", "investigation-C")
|
||||||
|
replace("investigation-C", d.addr)
|
||||||
|
b := start("B", "investigation-B")
|
||||||
|
replace("investigation-B", d.addr)
|
||||||
|
a := start("A", "investigation-A")
|
||||||
|
replace("investigation-A", b.addr)
|
||||||
|
request := func() {
|
||||||
|
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
client := relayconnector.NewConnector(connector.LoggerOption(log))
|
||||||
|
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e = conn.Write([]byte("probe")); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
if _, e = io.ReadFull(conn, buf); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if string(buf) != "probe" {
|
||||||
|
t.Fatalf("bad response %q", buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request()
|
||||||
|
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
|
||||||
|
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
replace("investigation-B", c.addr)
|
||||||
|
request()
|
||||||
|
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||||
|
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
// Deterministic cleanup boundary: DeleteChains has completed, while the B
|
||||||
|
// relay service is still alive (before a subsequent DeleteService command).
|
||||||
|
registry.ChainRegistry().Unregister("investigation-B")
|
||||||
|
request()
|
||||||
|
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||||
|
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
t.Log("Confirmed real relay data flow: A-B-D, then A-B-C-D, then A-B-target with B chain missing; no credentials supplied")
|
||||||
|
replace("investigation-B", c.addr)
|
||||||
|
request()
|
||||||
|
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
|
||||||
|
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package chain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corechain "github.com/go-gost/core/chain"
|
||||||
|
"github.com/go-gost/core/connector"
|
||||||
|
"github.com/go-gost/core/handler"
|
||||||
|
corelogger "github.com/go-gost/core/logger"
|
||||||
|
xauth "github.com/go-gost/x/auth"
|
||||||
|
xchain "github.com/go-gost/x/chain"
|
||||||
|
"github.com/go-gost/x/config"
|
||||||
|
parser "github.com/go-gost/x/config/parsing/chain"
|
||||||
|
relayconnector "github.com/go-gost/x/connector/relay"
|
||||||
|
_ "github.com/go-gost/x/dialer/tcp"
|
||||||
|
relayhandler "github.com/go-gost/x/handler/relay"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
"github.com/go-gost/x/metadata"
|
||||||
|
"github.com/go-gost/x/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This regression uses real loopback GOST relay endpoints and verifies the required-route policy.
|
||||||
|
// Every socket is bound to 127.0.0.1, no production config or external target.
|
||||||
|
func TestRequiredRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
|
||||||
|
testRequiredRelayRecovery(t, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredAuthenticatedRelayRouteRejectsMissingHopAndRecovers(t *testing.T) {
|
||||||
|
testRequiredRelayRecovery(t, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequiredRelayRecovery(t *testing.T, authenticated bool) {
|
||||||
|
t.Helper()
|
||||||
|
log := xlogger.Nop()
|
||||||
|
originalLogger := corelogger.Default()
|
||||||
|
corelogger.SetDefault(log)
|
||||||
|
if originalLogger != nil {
|
||||||
|
defer corelogger.SetDefault(originalLogger)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := target.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
type relay struct {
|
||||||
|
name string
|
||||||
|
addr string
|
||||||
|
count *atomic.Int32
|
||||||
|
}
|
||||||
|
start := func(name, chainName string) relay {
|
||||||
|
ln, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
router := xchain.NewRouter(corechain.ChainRouterOption(registry.ChainRegistry().Get(chainName)), corechain.LoggerRouterOption(log))
|
||||||
|
if chainName != "" {
|
||||||
|
router.WithRequiredChain()
|
||||||
|
}
|
||||||
|
opts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption(name)}
|
||||||
|
if authenticated {
|
||||||
|
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{name: "synthetic-local-password-for-" + name}))
|
||||||
|
if closer, ok := au.(io.Closer); ok {
|
||||||
|
t.Cleanup(func() { closer.Close() })
|
||||||
|
}
|
||||||
|
opts = append(opts, handler.AutherOption(au))
|
||||||
|
}
|
||||||
|
h := relayhandler.NewHandler(opts...)
|
||||||
|
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if closer, ok := h.(io.Closer); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
count := &atomic.Int32{}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := ln.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count.Add(1)
|
||||||
|
go h.Handle(ctx, c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return relay{name, ln.Addr().String(), count}
|
||||||
|
}
|
||||||
|
replace := func(name string, next relay) {
|
||||||
|
connectorConfig := &config.ConnectorConfig{Type: "relay", Metadata: map[string]any{"nodelay": true}}
|
||||||
|
if authenticated {
|
||||||
|
connectorConfig.Auth = &config.AuthConfig{Username: next.name, Password: "synthetic-local-password-for-" + next.name}
|
||||||
|
}
|
||||||
|
c, e := parser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: name + "-hop", Nodes: []*config.NodeConfig{{Name: name + "-next", Addr: next.addr, Connector: connectorConfig, Dialer: &config.DialerConfig{Type: "tcp"}}}}}}, log)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = registry.ReplaceChain(name, c); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"strict-regression-A", "strict-regression-B", "strict-regression-C"} {
|
||||||
|
name := name
|
||||||
|
t.Cleanup(func() { registry.ChainRegistry().Unregister(name) })
|
||||||
|
}
|
||||||
|
d := start("D", "")
|
||||||
|
c := start("C", "strict-regression-C")
|
||||||
|
replace("strict-regression-C", d)
|
||||||
|
b := start("B", "strict-regression-B")
|
||||||
|
replace("strict-regression-B", d)
|
||||||
|
a := start("A", "strict-regression-A")
|
||||||
|
replace("strict-regression-A", b)
|
||||||
|
request := func(wantSuccess bool) {
|
||||||
|
raw, e := net.DialTimeout("tcp", a.addr, time.Second)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
opts := []connector.Option{connector.LoggerOption(log)}
|
||||||
|
if authenticated {
|
||||||
|
opts = append(opts, connector.AuthOption(url.UserPassword("A", "synthetic-local-password-for-A")))
|
||||||
|
}
|
||||||
|
client := relayconnector.NewConnector(opts...)
|
||||||
|
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
conn, e := client.Connect(ctx, raw, "tcp", target.Addr().String())
|
||||||
|
if !wantSuccess {
|
||||||
|
if e == nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatal("relay connected through a missing required hop")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if _, e = conn.Write([]byte("probe")); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
if _, e = io.ReadFull(conn, buf); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if string(buf) != "probe" {
|
||||||
|
t.Fatalf("bad response %q", buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request(true)
|
||||||
|
if b.count.Load() != 1 || c.count.Load() != 0 || d.count.Load() != 1 {
|
||||||
|
t.Fatalf("initial B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
replace("strict-regression-B", c)
|
||||||
|
request(true)
|
||||||
|
if b.count.Load() != 2 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||||
|
t.Fatalf("insert C B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
// Deterministic cleanup boundary: DeleteChains has completed, while the B
|
||||||
|
// relay service is still alive (before a subsequent DeleteService command).
|
||||||
|
registry.ChainRegistry().Unregister("strict-regression-B")
|
||||||
|
request(false)
|
||||||
|
if b.count.Load() != 3 || c.count.Load() != 1 || d.count.Load() != 2 {
|
||||||
|
t.Fatalf("missing chain B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
t.Log("Required route refuses the missing B chain and recovers after replacement")
|
||||||
|
replace("strict-regression-B", c)
|
||||||
|
request(true)
|
||||||
|
if b.count.Load() != 4 || c.count.Load() != 2 || d.count.Load() != 3 {
|
||||||
|
t.Fatalf("restored B/C/D: %d/%d/%d", b.count.Load(), c.count.Load(), d.count.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package chain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gost/core/connector"
|
||||||
|
corelogger "github.com/go-gost/core/logger"
|
||||||
|
"github.com/go-gost/x/config"
|
||||||
|
"github.com/go-gost/x/config/parsing"
|
||||||
|
chainparser "github.com/go-gost/x/config/parsing/chain"
|
||||||
|
serviceparser "github.com/go-gost/x/config/parsing/service"
|
||||||
|
relayconnector "github.com/go-gost/x/connector/relay"
|
||||||
|
_ "github.com/go-gost/x/handler/relay"
|
||||||
|
_ "github.com/go-gost/x/listener/tcp"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
"github.com/go-gost/x/metadata"
|
||||||
|
"github.com/go-gost/x/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequiredRelayChainCannotDialTargetWhenMissing(t *testing.T) {
|
||||||
|
assertRequiredRelayCannotDial(t, "missing-required-chain", "tcp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredRelayChainRejectsEmptySelectionAndAbsentConfiguration(t *testing.T) {
|
||||||
|
corelogger.SetDefault(xlogger.Nop())
|
||||||
|
const name = "empty-required-chain"
|
||||||
|
c, err := chainparser.ParseChain(&config.ChainConfig{Name: name, Hops: []*config.HopConfig{{Name: "empty-hop", Nodes: []*config.NodeConfig{}}}}, xlogger.Nop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := registry.ReplaceChain(name, c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer registry.ChainRegistry().Unregister(name)
|
||||||
|
for _, network := range []string{"tcp", "udp"} {
|
||||||
|
for _, chainName := range []string{name, ""} {
|
||||||
|
t.Run(network+"/"+chainName, func(t *testing.T) { assertRequiredRelayCannotDial(t, chainName, network) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRequiredRelayCannotDial(t *testing.T, chainName, network string) {
|
||||||
|
t.Helper()
|
||||||
|
corelogger.SetDefault(xlogger.Nop())
|
||||||
|
previousTLS := parsing.DefaultTLSConfig()
|
||||||
|
parsing.SetDefaultTLSConfig(&tls.Config{})
|
||||||
|
defer parsing.SetDefaultTLSConfig(previousTLS)
|
||||||
|
target, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
var accepted atomic.Int32
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := target.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted.Add(1)
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
svc, err := serviceparser.ParseService(&config.ServiceConfig{Name: "required-route-regression", Addr: "127.0.0.1:0", Listener: &config.ListenerConfig{Type: "tcp"}, Handler: &config.HandlerConfig{Type: "relay", Chain: chainName, Metadata: map[string]any{"chain.strict": true, "nodelay": true}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { defer close(done); svc.Serve() }()
|
||||||
|
defer func() { svc.Close(); <-done }()
|
||||||
|
raw, err := net.DialTimeout("tcp", svc.Addr().String(), time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
client := relayconnector.NewConnector(connector.LoggerOption(xlogger.Nop()))
|
||||||
|
if err := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conn, err := client.Connect(context.Background(), raw, network, target.Addr().String())
|
||||||
|
if err == nil {
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
t.Fatal("required chain is missing, but relay accepted a direct connection")
|
||||||
|
}
|
||||||
|
if accepted.Load() != 0 {
|
||||||
|
t.Fatalf("ultimate target received %d connections without the required chain", accepted.Load())
|
||||||
|
}
|
||||||
|
if closer, ok := client.(io.Closer); ok {
|
||||||
|
closer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package chain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corechain "github.com/go-gost/core/chain"
|
||||||
|
"github.com/go-gost/core/connector"
|
||||||
|
"github.com/go-gost/core/dialer"
|
||||||
|
"github.com/go-gost/core/handler"
|
||||||
|
"github.com/go-gost/core/listener"
|
||||||
|
corelogger "github.com/go-gost/core/logger"
|
||||||
|
xauth "github.com/go-gost/x/auth"
|
||||||
|
xchain "github.com/go-gost/x/chain"
|
||||||
|
relayconnector "github.com/go-gost/x/connector/relay"
|
||||||
|
kcpdialer "github.com/go-gost/x/dialer/kcp"
|
||||||
|
relayhandler "github.com/go-gost/x/handler/relay"
|
||||||
|
netdialer "github.com/go-gost/x/internal/net/dialer"
|
||||||
|
kcplistener "github.com/go-gost/x/listener/kcp"
|
||||||
|
tcplistener "github.com/go-gost/x/listener/tcp"
|
||||||
|
tlslistener "github.com/go-gost/x/listener/tls"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
"github.com/go-gost/x/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Required intermediates reject missing chains on every supported transport.
|
||||||
|
// Legitimate authenticated exits remain direct. All credentials and endpoints are local test data.
|
||||||
|
func TestRequiredRelayRouteTransportMatrix(t *testing.T) {
|
||||||
|
log := xlogger.Nop()
|
||||||
|
corelogger.SetDefault(log)
|
||||||
|
certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
cert := certServer.TLS.Certificates[0]
|
||||||
|
leaf := certServer.Certificate()
|
||||||
|
certServer.Close()
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(leaf)
|
||||||
|
tcpTarget, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer tcpTarget.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, e := tcpTarget.Accept()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() { defer c.Close(); io.Copy(c, c) }()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
udpTarget, e := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer udpTarget.Close()
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, a, e := udpTarget.ReadFrom(buf)
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpTarget.WriteTo(buf[:n], a)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, transport := range []string{"tcp", "tls", "kcp"} {
|
||||||
|
for _, network := range []string{"tcp", "udp"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
required bool
|
||||||
|
credentials *url.Userinfo
|
||||||
|
success bool
|
||||||
|
}{
|
||||||
|
{"authenticated-exit", false, url.UserPassword("test-peer", "synthetic-test-secret"), true},
|
||||||
|
{"required-missing-chain", true, url.UserPassword("test-peer", "synthetic-test-secret"), false},
|
||||||
|
} {
|
||||||
|
t.Run(transport+"/"+network+"/"+test.name, func(t *testing.T) {
|
||||||
|
opts := []listener.Option{listener.AddrOption("127.0.0.1:0"), listener.LoggerOption(log), listener.TLSConfigOption(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})}
|
||||||
|
var ln listener.Listener
|
||||||
|
switch transport {
|
||||||
|
case "tls":
|
||||||
|
ln = tlslistener.NewListener(opts...)
|
||||||
|
case "kcp":
|
||||||
|
ln = kcplistener.NewListener(opts...)
|
||||||
|
default:
|
||||||
|
ln = tcplistener.NewListener(opts...)
|
||||||
|
}
|
||||||
|
md := metadata.NewMetadata(map[string]any{"kcp.tcp": false, "kcp.nocomp": true})
|
||||||
|
if e := ln.Init(md); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
router := xchain.NewRouter(corechain.LoggerRouterOption(log))
|
||||||
|
if test.required {
|
||||||
|
router.WithRequiredChain()
|
||||||
|
}
|
||||||
|
hopts := []handler.Option{handler.RouterOption(router), handler.LoggerOption(log), handler.ServiceOption("strict-transport-regression")}
|
||||||
|
{
|
||||||
|
au := xauth.NewAuthenticator(xauth.AuthsOption(map[string]string{"test-peer": "synthetic-test-secret"}))
|
||||||
|
if closer, ok := au.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
hopts = append(hopts, handler.AutherOption(au))
|
||||||
|
}
|
||||||
|
h := relayhandler.NewHandler(hopts...)
|
||||||
|
if e := h.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if closer, ok := h.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
handled := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(handled)
|
||||||
|
c, e := ln.Accept()
|
||||||
|
if e == nil {
|
||||||
|
h.Handle(context.Background(), c)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var raw net.Conn
|
||||||
|
var err error
|
||||||
|
switch transport {
|
||||||
|
case "tls":
|
||||||
|
raw, err = tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", ln.Addr().String(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
|
||||||
|
case "kcp":
|
||||||
|
d := kcpdialer.NewDialer(dialer.LoggerOption(log))
|
||||||
|
if e := d.Init(md); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if closer, ok := d.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
raw, err = d.Dial(context.Background(), ln.Addr().String(), dialer.NetDialerDialOption(&netdialer.Dialer{Logger: log}))
|
||||||
|
default:
|
||||||
|
raw, err = net.DialTimeout("tcp", ln.Addr().String(), time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer raw.Close()
|
||||||
|
raw.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
copts := []connector.Option{connector.LoggerOption(log)}
|
||||||
|
if test.credentials != nil {
|
||||||
|
copts = append(copts, connector.AuthOption(test.credentials))
|
||||||
|
}
|
||||||
|
client := relayconnector.NewConnector(copts...)
|
||||||
|
if e := client.Init(metadata.NewMetadata(map[string]any{"nodelay": true})); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
target := tcpTarget.Addr().String()
|
||||||
|
if network == "udp" {
|
||||||
|
target = udpTarget.LocalAddr().String()
|
||||||
|
}
|
||||||
|
conn, err := client.Connect(context.Background(), raw, network, target)
|
||||||
|
if !test.success {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("required route missing but direct connection accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.Close()
|
||||||
|
<-handled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = conn.Write([]byte("probe")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := make([]byte, 64)
|
||||||
|
n, err := conn.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(b[:n]) != "probe" {
|
||||||
|
t.Fatalf("unexpected response %q", b[:n])
|
||||||
|
}
|
||||||
|
raw.Close()
|
||||||
|
select {
|
||||||
|
case <-handled:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("handler did not close")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
options chain.RouterOptions
|
options chain.RouterOptions
|
||||||
|
requireChain bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(opts ...chain.RouterOption) *Router {
|
func NewRouter(opts ...chain.RouterOption) *Router {
|
||||||
@@ -35,6 +36,13 @@ func NewRouter(opts ...chain.RouterOption) *Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithRequiredChain disables direct fallback when an explicitly required chain
|
||||||
|
// is absent or selects no route. Configure it before serving requests.
|
||||||
|
func (r *Router) WithRequiredChain() *Router {
|
||||||
|
r.requireChain = true
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) Options() *chain.RouterOptions {
|
func (r *Router) Options() *chain.RouterOptions {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -117,6 +125,10 @@ func (r *Router) dial(ctx context.Context, network, address string, log logger.L
|
|||||||
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
route = r.options.Chain.Route(ctx, network, ipAddr, chain.WithHostRouteOption(address))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.requireChain && (route == nil || len(route.Nodes()) == 0) {
|
||||||
|
return nil, ErrEmptyRoute
|
||||||
|
}
|
||||||
|
|
||||||
if buf == nil {
|
if buf == nil {
|
||||||
buf = &bytes.Buffer{}
|
buf = &bytes.Buffer{}
|
||||||
}
|
}
|
||||||
@@ -164,6 +176,9 @@ func (r *Router) Bind(ctx context.Context, network, address string, opts ...chai
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.requireChain && r.options.Chain == nil {
|
||||||
|
return nil, ErrEmptyRoute
|
||||||
|
}
|
||||||
var route chain.Route
|
var route chain.Route
|
||||||
if r.options.Chain != nil {
|
if r.options.Chain != nil {
|
||||||
route = r.options.Chain.Route(ctx, network, address)
|
route = r.options.Chain.Route(ctx, network, address)
|
||||||
|
|||||||
@@ -327,10 +327,15 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlerRouter := xchain.NewRouter(routerOpts...)
|
||||||
|
if mdutil.GetBool(metadata.NewMetadata(cfg.Handler.Metadata), "chain.strict") {
|
||||||
|
handlerRouter.WithRequiredChain()
|
||||||
|
}
|
||||||
|
|
||||||
var h handler.Handler
|
var h handler.Handler
|
||||||
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
|
if rf := registry.HandlerRegistry().Get(cfg.Handler.Type); rf != nil {
|
||||||
h = rf(
|
h = rf(
|
||||||
handler.RouterOption(xchain.NewRouter(routerOpts...)),
|
handler.RouterOption(handlerRouter),
|
||||||
handler.AutherOption(auther),
|
handler.AutherOption(auther),
|
||||||
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
handler.AuthOption(auth_parser.Info(cfg.Handler.Auth)),
|
||||||
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
handler.BypassOption(xbypass.BypassGroup(bypass_parser.List(cfg.Bypass, cfg.Bypasses...)...)),
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
module github.com/go-gost/x
|
module github.com/go-gost/x
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.10
|
||||||
|
|
||||||
require github.com/shirou/gopsutil/v3 v3.24.5
|
require github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package udp
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,6 +74,7 @@ func (ln *listener) listenLoop() {
|
|||||||
|
|
||||||
n, raddr, err := ln.conn.ReadFrom(b)
|
n, raddr, err := ln.conn.ReadFrom(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
bufpool.Put(b)
|
||||||
ln.errChan <- err
|
ln.errChan <- err
|
||||||
close(ln.errChan)
|
close(ln.errChan)
|
||||||
return
|
return
|
||||||
@@ -110,15 +112,27 @@ func (ln *listener) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ln *listener) getConn(raddr net.Addr) *conn {
|
func (ln *listener) getConn(raddr net.Addr) *conn {
|
||||||
c, ok := ln.connPool.Get(raddr.String())
|
laddr := ln.Addr()
|
||||||
|
peer := raddr
|
||||||
|
var packet *packetAddr
|
||||||
|
if received, ok := raddr.(*packetAddr); ok {
|
||||||
|
packet = received
|
||||||
|
laddr, peer = received.local, received.Addr
|
||||||
|
}
|
||||||
|
key := laddr.String() + "|" + peer.String()
|
||||||
|
if packet != nil {
|
||||||
|
key += "|" + strconv.Itoa(packet.ifIndex)
|
||||||
|
}
|
||||||
|
c, ok := ln.connPool.Get(key)
|
||||||
if ok && !c.isClosed() {
|
if ok && !c.isClosed() {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
c = newConn(ln.conn, ln.Addr(), raddr, ln.config.ReadQueueSize, ln.config.Keepalive)
|
c = newConn(ln.conn, laddr, peer, ln.config.ReadQueueSize, ln.config.Keepalive)
|
||||||
|
c.packet = packet
|
||||||
select {
|
select {
|
||||||
case ln.cqueue <- c:
|
case ln.cqueue <- c:
|
||||||
ln.connPool.Set(raddr.String(), c)
|
ln.connPool.Set(key, c)
|
||||||
return c
|
return c
|
||||||
default:
|
default:
|
||||||
c.Close()
|
c.Close()
|
||||||
@@ -132,6 +146,7 @@ type conn struct {
|
|||||||
net.PacketConn
|
net.PacketConn
|
||||||
localAddr net.Addr
|
localAddr net.Addr
|
||||||
remoteAddr net.Addr
|
remoteAddr net.Addr
|
||||||
|
packet *packetAddr
|
||||||
rc chan []byte // data receive queue
|
rc chan []byte // data receive queue
|
||||||
idle int32 // indicate the connection is idle
|
idle int32 // indicate the connection is idle
|
||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
@@ -176,6 +191,9 @@ func (c *conn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
|
|||||||
if !c.keepalive {
|
if !c.keepalive {
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
}
|
}
|
||||||
|
if c.packet != nil {
|
||||||
|
addr = &packetAddr{Addr: addr, local: c.packet.local, control: c.packet.control, ifIndex: c.packet.ifIndex}
|
||||||
|
}
|
||||||
return c.PacketConn.WriteTo(b, addr)
|
return c.PacketConn.WriteTo(b, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
// packetAddr carries the received destination through the PacketConn wrappers.
|
||||||
|
// Its public address remains the real peer for admission, metrics and limits.
|
||||||
|
type packetAddr struct {
|
||||||
|
net.Addr
|
||||||
|
local *net.UDPAddr
|
||||||
|
control []byte
|
||||||
|
ifIndex int
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"golang.org/x/net/ipv6"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreserveDestination pins wildcard UDP replies to the address they arrived on.
|
||||||
|
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
|
||||||
|
local := c.LocalAddr().(*net.UDPAddr)
|
||||||
|
if !local.IP.IsUnspecified() {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
raw, err := c.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sockErr error
|
||||||
|
if err = raw.Control(func(fd uintptr) {
|
||||||
|
if local.IP.To4() != nil {
|
||||||
|
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_RECVPKTINFO, 1); sockErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var onlyV6 int
|
||||||
|
onlyV6, sockErr = unix.GetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY)
|
||||||
|
if sockErr == nil && onlyV6 == 0 {
|
||||||
|
sockErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_PKTINFO, 1)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sockErr != nil {
|
||||||
|
return nil, sockErr
|
||||||
|
}
|
||||||
|
return &destinationConn{UDPConn: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type destinationConn struct {
|
||||||
|
*net.UDPConn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *destinationConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||||
|
var oob [128]byte
|
||||||
|
n, oobn, flags, remote, err := c.ReadMsgUDP(b, oob[:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, remote, err
|
||||||
|
}
|
||||||
|
if flags&unix.MSG_CTRUNC != 0 {
|
||||||
|
return 0, nil, errors.New("UDP destination control message truncated")
|
||||||
|
}
|
||||||
|
var dst net.IP
|
||||||
|
var control []byte
|
||||||
|
var ifIndex int
|
||||||
|
if remote.IP.To4() != nil {
|
||||||
|
messages, err := unix.ParseSocketControlMessage(oob[:oobn])
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
for _, message := range messages {
|
||||||
|
if message.Header.Level != unix.IPPROTO_IP || message.Header.Type != unix.IP_PKTINFO {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var info unix.Inet4Pktinfo
|
||||||
|
if _, err := binary.Decode(message.Data, binary.NativeEndian, &info); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
dst, ifIndex = net.IP(info.Addr[:]), int(info.Ifindex)
|
||||||
|
// Spec_dst remains a local unicast address for broadcast destinations.
|
||||||
|
control = unix.PktInfo4(&unix.Inet4Pktinfo{Ifindex: info.Ifindex, Spec_dst: info.Spec_dst})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var cm ipv6.ControlMessage
|
||||||
|
if err = cm.Parse(oob[:oobn]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
dst, ifIndex = cm.Dst, cm.IfIndex
|
||||||
|
source := dst
|
||||||
|
if dst.IsMulticast() {
|
||||||
|
source = nil
|
||||||
|
}
|
||||||
|
control = (&ipv6.ControlMessage{Src: source, IfIndex: ifIndex}).Marshal()
|
||||||
|
}
|
||||||
|
if len(dst) == 0 || dst.IsUnspecified() {
|
||||||
|
return 0, nil, errors.New("UDP destination address missing")
|
||||||
|
}
|
||||||
|
local := &net.UDPAddr{IP: append(net.IP(nil), dst...), Port: c.LocalAddr().(*net.UDPAddr).Port}
|
||||||
|
if dst.To4() == nil && (dst.IsLinkLocalUnicast() || dst.IsLinkLocalMulticast() || dst.IsInterfaceLocalMulticast()) {
|
||||||
|
if iface, lookupErr := net.InterfaceByIndex(ifIndex); lookupErr == nil {
|
||||||
|
local.Zone = iface.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, &packetAddr{Addr: remote, local: local, control: control, ifIndex: ifIndex}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *destinationConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||||
|
if packet, ok := addr.(*packetAddr); ok {
|
||||||
|
remote, ok := packet.Addr.(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
|
return 0, errors.New("invalid UDP peer address")
|
||||||
|
}
|
||||||
|
n, _, err := c.WriteMsgUDP(b, packet.control, remote)
|
||||||
|
if n < 0 {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
return c.UDPConn.WriteTo(b, addr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.org/x/net/ipv6"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMulticastDestinationRepliesFromUnicast(t *testing.T) {
|
||||||
|
rxName, txName := os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_TX")
|
||||||
|
if rxName == "" || txName == "" {
|
||||||
|
t.Skip("requires isolated multicast veth interfaces")
|
||||||
|
}
|
||||||
|
rx, err := net.InterfaceByName(rxName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tx, err := net.InterfaceByName(txName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
group := net.ParseIP("ff02::114")
|
||||||
|
socket, err := net.ListenMulticastUDP("udp6", rx, &net.UDPAddr{IP: group})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer socket.Close()
|
||||||
|
pc, err := PreserveDestination(socket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ln := NewListener(pc, &ListenConfig{
|
||||||
|
Backlog: 1, ReadQueueSize: 1, ReadBufferSize: 128, TTL: time.Second, Keepalive: true,
|
||||||
|
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
|
||||||
|
})
|
||||||
|
defer ln.Close()
|
||||||
|
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: txName})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
port := socket.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
serverResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
serverResult <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
|
||||||
|
if !local.IP.Equal(group) || local.Port != port || local.Zone != rxName || !peer.IP.Equal(net.ParseIP("fe80::1")) ||
|
||||||
|
peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != rxName {
|
||||||
|
serverResult <- fmt.Errorf("unexpected multicast session: local=%s peer=%s", local, peer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf [128]byte
|
||||||
|
n, err := c.Read(buf[:])
|
||||||
|
if err == nil {
|
||||||
|
_, err = c.Write(buf[:n])
|
||||||
|
}
|
||||||
|
serverResult <- err
|
||||||
|
}()
|
||||||
|
if _, err := client.WriteToUDP([]byte("multicast"), &net.UDPAddr{IP: group, Port: port, Zone: txName}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-serverResult:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("multicast session timed out")
|
||||||
|
}
|
||||||
|
var buf [128]byte
|
||||||
|
n, from, err := client.ReadFromUDP(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !from.IP.Equal(net.ParseIP("fe80::2")) || from.Port != port || from.Zone != txName || string(buf[:n]) != "multicast" {
|
||||||
|
t.Fatalf("unexpected multicast reply %q from %s", buf[:n], from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkLocalMulticastSessionsRetainZones(t *testing.T) {
|
||||||
|
testScopedMulticastSessions(t, "ff02::114", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPv4DestinationSessionsRetainInterfaces(t *testing.T) {
|
||||||
|
ifNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
|
||||||
|
if ifNames[0] == "" || ifNames[1] == "" {
|
||||||
|
t.Skip("requires two isolated IPv4 multicast interfaces")
|
||||||
|
}
|
||||||
|
for _, network := range []string{"udp4", "udp"} {
|
||||||
|
for _, destination := range []string{"239.255.0.114", "224.0.0.114", "255.255.255.255"} {
|
||||||
|
t.Run(network+"/"+destination, func(t *testing.T) {
|
||||||
|
bind := net.IPv4zero
|
||||||
|
if network == "udp" {
|
||||||
|
bind = net.IPv6unspecified
|
||||||
|
}
|
||||||
|
server, err := net.ListenUDP(network, &net.UDPAddr{IP: bind})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
pc, err := PreserveDestination(server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("198.18.0.1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
raw, err := client.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sockErr error
|
||||||
|
if err := raw.Control(func(fd uintptr) {
|
||||||
|
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sockErr != nil {
|
||||||
|
t.Fatal(sockErr)
|
||||||
|
}
|
||||||
|
target := &net.UDPAddr{IP: net.ParseIP(destination), Port: server.LocalAddr().(*net.UDPAddr).Port}
|
||||||
|
for _, name := range ifNames {
|
||||||
|
iface, err := net.InterfaceByName(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if target.IP.IsMulticast() {
|
||||||
|
if err := ipv4.NewPacketConn(server).JoinGroup(iface, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ipv4.NewPacketConn(client).WriteTo([]byte(name), &ipv4.ControlMessage{
|
||||||
|
Src: client.LocalAddr().(*net.UDPAddr).IP, IfIndex: iface.Index,
|
||||||
|
}, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
ln := NewListener(pc, &ListenConfig{
|
||||||
|
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
|
||||||
|
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
|
||||||
|
})
|
||||||
|
defer ln.Close()
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for range ifNames {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("interface did not get a separate session: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
var buf [128]byte
|
||||||
|
n, err := c.Read(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := string(buf[:n])
|
||||||
|
if (name != ifNames[0] && name != ifNames[1]) || seen[name] ||
|
||||||
|
c.LocalAddr().String() != target.String() || c.RemoteAddr().String() != client.LocalAddr().String() {
|
||||||
|
t.Fatalf("unexpected interface session: data=%q local=%s peer=%s", buf[:n], c.LocalAddr(), c.RemoteAddr())
|
||||||
|
}
|
||||||
|
iface, err := net.InterfaceByName(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
messages, err := unix.ParseSocketControlMessage(c.(*conn).packet.control)
|
||||||
|
if err != nil || len(messages) != 1 {
|
||||||
|
t.Fatalf("invalid reply control: %v", err)
|
||||||
|
}
|
||||||
|
var info unix.Inet4Pktinfo
|
||||||
|
if _, err := binary.Decode(messages[0].Data, binary.NativeEndian, &info); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if int(info.Ifindex) != iface.Index {
|
||||||
|
t.Fatalf("reply interface = %d, want %d", info.Ifindex, iface.Index)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceLocalMulticastSessionsRetainZones(t *testing.T) {
|
||||||
|
testScopedMulticastSessions(t, "ff01::114", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScopedMulticastSessions(t *testing.T, destination string, interfaceLocal bool) {
|
||||||
|
t.Helper()
|
||||||
|
rxNames := []string{os.Getenv("FLUX_TEST_MULTICAST_RX"), os.Getenv("FLUX_TEST_MULTICAST_RX2")}
|
||||||
|
txNames := []string{os.Getenv("FLUX_TEST_MULTICAST_TX"), os.Getenv("FLUX_TEST_MULTICAST_TX2")}
|
||||||
|
for i := range rxNames {
|
||||||
|
if rxNames[i] == "" || txNames[i] == "" {
|
||||||
|
t.Skip("requires two isolated multicast veth pairs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interfaceLocal {
|
||||||
|
// Interface-local multicast is looped back on the sending interface.
|
||||||
|
rxNames = txNames
|
||||||
|
}
|
||||||
|
group := net.ParseIP(destination)
|
||||||
|
server, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6unspecified})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
clientIP := net.ParseIP("fd00:1::1")
|
||||||
|
client, err := net.ListenUDP("udp6", &net.UDPAddr{IP: clientIP})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
pc, err := PreserveDestination(server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, rxName := range rxNames {
|
||||||
|
rx, err := net.InterfaceByName(rxName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ipv6.NewPacketConn(server).JoinGroup(rx, &net.UDPAddr{IP: group}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tx, err := net.InterfaceByName(txNames[i])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ipv6.NewPacketConn(client).SetMulticastInterface(tx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := client.WriteToUDP([]byte(rxName), &net.UDPAddr{
|
||||||
|
IP: group, Port: server.LocalAddr().(*net.UDPAddr).Port, Zone: txNames[i],
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
ln := NewListener(pc, &ListenConfig{
|
||||||
|
Backlog: 2, ReadQueueSize: 2, ReadBufferSize: 128, TTL: 10 * time.Second, Keepalive: true,
|
||||||
|
Logger: xlogger.NewLogger(xlogger.OutputOption(io.Discard)),
|
||||||
|
})
|
||||||
|
defer ln.Close()
|
||||||
|
first, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer first.Close()
|
||||||
|
second, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second interface did not get a separate session: %v", err)
|
||||||
|
}
|
||||||
|
defer second.Close()
|
||||||
|
if first == second {
|
||||||
|
t.Fatal("interface-scoped multicast sessions were shared")
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, c := range []net.Conn{first, second} {
|
||||||
|
var buf [128]byte
|
||||||
|
n, err := c.Read(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zone := string(buf[:n])
|
||||||
|
local, peer := c.LocalAddr().(*net.UDPAddr), c.RemoteAddr().(*net.UDPAddr)
|
||||||
|
if (zone != rxNames[0] && zone != rxNames[1]) || seen[zone] || !local.IP.Equal(group) ||
|
||||||
|
local.Port != server.LocalAddr().(*net.UDPAddr).Port || local.Zone != zone ||
|
||||||
|
!peer.IP.Equal(clientIP) || peer.Port != client.LocalAddr().(*net.UDPAddr).Port || peer.Zone != "" {
|
||||||
|
t.Fatalf("unexpected scoped session: data=%q local=%s peer=%s", buf[:n], local, peer)
|
||||||
|
}
|
||||||
|
var reply ipv6.ControlMessage
|
||||||
|
if err := reply.Parse(c.(*conn).packet.control); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iface, err := net.InterfaceByName(zone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reply.IfIndex != iface.Index {
|
||||||
|
t.Fatalf("reply interface = %d, want %d", reply.IfIndex, iface.Index)
|
||||||
|
}
|
||||||
|
seen[zone] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package udp
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
corelimiter "github.com/go-gost/core/limiter"
|
||||||
|
coretraffic "github.com/go-gost/core/limiter/traffic"
|
||||||
|
corelistener "github.com/go-gost/core/listener"
|
||||||
|
corestats "github.com/go-gost/core/observer/stats"
|
||||||
|
xadmission "github.com/go-gost/x/admission"
|
||||||
|
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||||
|
xlogger "github.com/go-gost/x/logger"
|
||||||
|
xmetadata "github.com/go-gost/x/metadata"
|
||||||
|
"github.com/go-gost/x/metrics"
|
||||||
|
xstats "github.com/go-gost/x/observer/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
type countedTraffic struct {
|
||||||
|
coretraffic.TrafficLimiter
|
||||||
|
in, out atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *countedTraffic) In(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
|
||||||
|
l.in.Add(1)
|
||||||
|
return l.TrafficLimiter.In(ctx, key, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *countedTraffic) Out(ctx context.Context, key string, opts ...corelimiter.Option) coretraffic.Limiter {
|
||||||
|
l.out.Add(1)
|
||||||
|
return l.TrafficLimiter.Out(ctx, key, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestinationPreservedThroughWrappers(t *testing.T) {
|
||||||
|
v4 := []string{"127.0.0.1", "127.0.0.2"}
|
||||||
|
if address := os.Getenv("FLUX_TEST_IPV4_LINK_LOCAL"); address != "" {
|
||||||
|
v4 = append(v4, address)
|
||||||
|
}
|
||||||
|
v6 := strings.FieldsFunc(os.Getenv("FLUX_TEST_IPV6_ADDRS"), func(r rune) bool { return r == ',' })
|
||||||
|
if len(v6) == 0 {
|
||||||
|
v6 = []string{"::1"}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, bind string
|
||||||
|
targets []string
|
||||||
|
clientBind string
|
||||||
|
}{
|
||||||
|
{"ipv4-wildcard", "0.0.0.0:0", v4, ""},
|
||||||
|
{"dual-stack-wildcard", "[::]:0", append(v4, v6...), ""},
|
||||||
|
{"fixed-ipv6-source", "[::]:0", v6, "::1"},
|
||||||
|
{"explicit-ipv4", "127.0.0.1:0", []string{"127.0.0.1"}, ""},
|
||||||
|
{"explicit-ipv6", "[::1]:0", []string{"::1"}, ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
metrics.Enable(true)
|
||||||
|
defer metrics.Enable(false)
|
||||||
|
log := xlogger.NewLogger(xlogger.OutputOption(io.Discard))
|
||||||
|
stats := xstats.NewStats(false)
|
||||||
|
lim := &countedTraffic{TrafficLimiter: xtraffic.NewTrafficLimiter(
|
||||||
|
xtraffic.LimitsOption("$ 1MB 1MB", "$$ 1MB 1MB"), xtraffic.LoggerOption(log),
|
||||||
|
)}
|
||||||
|
ln := NewListener(corelistener.AddrOption(tc.bind), corelistener.LoggerOption(log),
|
||||||
|
corelistener.ServiceOption(t.Name()), corelistener.StatsOption(stats),
|
||||||
|
corelistener.TrafficLimiterOption(lim),
|
||||||
|
corelistener.AdmissionOption(xadmission.NewAdmission(
|
||||||
|
xadmission.WhitelistOption(true),
|
||||||
|
xadmission.MatchersOption([]string{"127.0.0.0/8", "169.254.0.0/16", "::1", "fd00::/8"}),
|
||||||
|
xadmission.LoggerOption(log),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
clientIP := net.IPv6unspecified
|
||||||
|
if tc.clientBind != "" {
|
||||||
|
clientIP = net.ParseIP(tc.clientBind)
|
||||||
|
}
|
||||||
|
client, err := net.ListenUDP("udp", &net.UDPAddr{IP: clientIP})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
client.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
clientAddr := client.LocalAddr().(*net.UDPAddr)
|
||||||
|
var accepted atomic.Int64
|
||||||
|
errors := make(chan error, 128)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer c.Close()
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
for {
|
||||||
|
n, err := c.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var round, target int
|
||||||
|
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &round, &target); err != nil {
|
||||||
|
errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
local := c.LocalAddr().(*net.UDPAddr)
|
||||||
|
if local.IP.To4() != nil && local.Zone != "" {
|
||||||
|
errors <- fmt.Errorf("IPv4 destination has a zone: %s", local)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !local.IP.Equal(net.ParseIP(tc.targets[target])) {
|
||||||
|
errors <- fmt.Errorf("local destination = %s, want %s", local.IP, tc.targets[target])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer, ok := c.RemoteAddr().(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
|
errors <- fmt.Errorf("peer type changed: %T", c.RemoteAddr())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tc.clientBind != "" && (!peer.IP.Equal(clientAddr.IP) || peer.Port != clientAddr.Port) {
|
||||||
|
errors <- fmt.Errorf("peer endpoint = %s, want %s", peer, clientAddr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := c.Write(buf[:n]); err != nil {
|
||||||
|
errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
port := ln.Addr().(*net.UDPAddr).Port
|
||||||
|
var bytes int
|
||||||
|
for round := 0; round < 3; round++ {
|
||||||
|
for target, ip := range tc.targets {
|
||||||
|
payload := []byte(fmt.Sprintf("%d/%d", round, target))
|
||||||
|
bytes += len(payload)
|
||||||
|
if _, err := client.WriteToUDP(payload, &net.UDPAddr{IP: net.ParseIP(ip), Port: port}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := make(map[int]bool)
|
||||||
|
for range tc.targets {
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
n, from, err := client.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case serverErr := <-errors:
|
||||||
|
t.Fatal(serverErr)
|
||||||
|
default:
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var gotRound, target int
|
||||||
|
if _, err := fmt.Sscanf(string(buf[:n]), "%d/%d", &gotRound, &target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotRound != round || seen[target] || from.Port != port || !from.IP.Equal(net.ParseIP(tc.targets[target])) {
|
||||||
|
t.Fatalf("unexpected reply %q from %s", buf[:n], from)
|
||||||
|
}
|
||||||
|
seen[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accepted.Load() != int64(len(tc.targets)) {
|
||||||
|
t.Fatalf("accepted %d sessions, want %d", accepted.Load(), len(tc.targets))
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for stats.Get(corestats.KindOutputBytes) < uint64(bytes) && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
if stats.Get(corestats.KindInputBytes) != uint64(bytes) || stats.Get(corestats.KindOutputBytes) != uint64(bytes) {
|
||||||
|
t.Fatalf("traffic stats lost: in=%d out=%d want=%d", stats.Get(corestats.KindInputBytes), stats.Get(corestats.KindOutputBytes), bytes)
|
||||||
|
}
|
||||||
|
if lim.in.Load() == 0 || lim.out.Load() == 0 {
|
||||||
|
t.Fatal("traffic limiter bypassed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastDestinationRepliesFromUnicast(t *testing.T) {
|
||||||
|
for _, bind := range []string{"0.0.0.0:0", "[::]:0"} {
|
||||||
|
t.Run(bind, func(t *testing.T) {
|
||||||
|
ln := NewListener(corelistener.AddrOption(bind),
|
||||||
|
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
|
||||||
|
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
raw, err := client.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var sockErr error
|
||||||
|
if err := raw.Control(func(fd uintptr) {
|
||||||
|
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_BROADCAST, 1)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sockErr != nil {
|
||||||
|
t.Fatal(sockErr)
|
||||||
|
}
|
||||||
|
client.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
target := &net.UDPAddr{IP: net.IPv4(127, 255, 255, 255), Port: ln.Addr().(*net.UDPAddr).Port}
|
||||||
|
if _, err := client.WriteToUDP([]byte("broadcast"), target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conn, err := acceptWithTimeout(t, ln, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if conn.LocalAddr().String() != target.String() {
|
||||||
|
t.Fatalf("local destination = %s, want %s", conn.LocalAddr(), target)
|
||||||
|
}
|
||||||
|
if conn.RemoteAddr().String() != client.LocalAddr().String() {
|
||||||
|
t.Fatalf("peer endpoint = %s, want %s", conn.RemoteAddr(), client.LocalAddr())
|
||||||
|
}
|
||||||
|
var buf [128]byte
|
||||||
|
n, err := conn.Read(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(buf[:n]); err != nil {
|
||||||
|
t.Fatalf("reply to broadcast request: %v", err)
|
||||||
|
}
|
||||||
|
n, from, err := client.ReadFromUDP(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !from.IP.Equal(net.IPv4(127, 0, 0, 1)) || from.Port != target.Port || string(buf[:n]) != "broadcast" {
|
||||||
|
t.Fatalf("unexpected broadcast reply %q from %s", buf[:n], from)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestinationSessionExpires(t *testing.T) {
|
||||||
|
ln := NewListener(corelistener.AddrOption("0.0.0.0:0"),
|
||||||
|
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard))))
|
||||||
|
if err := ln.Init(xmetadata.NewMetadata(map[string]any{"keepAlive": true, "ttl": "20ms"})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
client, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2), Port: ln.Addr().(*net.UDPAddr).Port})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
client.Write([]byte("first"))
|
||||||
|
first, err := acceptWithTimeout(t, ln, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var b [16]byte
|
||||||
|
first.Read(b[:])
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
if _, err := first.Read(b[:]); err == nil {
|
||||||
|
t.Fatal("idle session did not expire")
|
||||||
|
}
|
||||||
|
client.Write([]byte("second"))
|
||||||
|
second, err := acceptWithTimeout(t, ln, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer second.Close()
|
||||||
|
if first == second {
|
||||||
|
t.Fatal("expired session reused")
|
||||||
|
}
|
||||||
|
if second.LocalAddr().(*net.UDPAddr).IP.String() != "127.0.0.2" {
|
||||||
|
t.Fatalf("new session lost destination: %s", second.LocalAddr())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,11 +56,15 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var conn net.PacketConn
|
socket, err := net.ListenUDP(network, laddr)
|
||||||
conn, err = net.ListenUDP(network, laddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
conn, err := udp.PreserveDestination(socket)
|
||||||
|
if err != nil {
|
||||||
|
socket.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
conn = metrics.WrapPacketConn(l.options.Service, conn)
|
conn = metrics.WrapPacketConn(l.options.Service, conn)
|
||||||
conn = stats.WrapPacketConn(conn, l.options.Stats)
|
conn = stats.WrapPacketConn(conn, l.options.Stats)
|
||||||
conn = admission.WrapPacketConn(l.options.Admission, conn)
|
conn = admission.WrapPacketConn(l.options.Admission, conn)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type agentServiceManager struct{ kind, control, runner string }
|
||||||
|
|
||||||
|
// Alpine must never select a systemctl compatibility shim. Elsewhere prefer
|
||||||
|
// an active systemd instance, matching the installer, then OpenRC.
|
||||||
|
func detectAgentServiceManager(alpine, systemd bool, lookup func(string) (string, error)) (agentServiceManager, error) {
|
||||||
|
if !alpine && systemd {
|
||||||
|
control, err := lookup("systemctl")
|
||||||
|
if err != nil {
|
||||||
|
return agentServiceManager{}, fmt.Errorf("systemctl 不可用: %w", err)
|
||||||
|
}
|
||||||
|
runner, err := lookup("systemd-run")
|
||||||
|
if err != nil {
|
||||||
|
return agentServiceManager{}, fmt.Errorf("systemd-run 不可用: %w", err)
|
||||||
|
}
|
||||||
|
return agentServiceManager{kind: "systemd", control: control, runner: runner}, nil
|
||||||
|
}
|
||||||
|
control, err := lookup("rc-service")
|
||||||
|
if err != nil {
|
||||||
|
return agentServiceManager{}, fmt.Errorf("未检测到受支持的服务管理器 (systemd/OpenRC): %w", err)
|
||||||
|
}
|
||||||
|
return agentServiceManager{kind: "openrc", control: control}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebSocketReporter) agentRestartLauncher(cleanLegacy bool) (func(string, string) error, error) {
|
||||||
|
// Existing per-reporter seam is also used by checksum-only tests.
|
||||||
|
if w.startUpgradeRestart != nil {
|
||||||
|
return w.startUpgradeRestart, nil
|
||||||
|
}
|
||||||
|
detect := w.detectAgentManager
|
||||||
|
if detect == nil {
|
||||||
|
detect = func() (agentServiceManager, error) {
|
||||||
|
_, alpineErr := os.Stat("/etc/alpine-release")
|
||||||
|
_, systemdErr := os.Stat("/run/systemd/system")
|
||||||
|
return detectAgentServiceManager(alpineErr == nil, systemdErr == nil, exec.LookPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager, err := detect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if manager.kind == "openrc" {
|
||||||
|
if err := checkAgentDetach(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func(staged, binary string) error {
|
||||||
|
script := buildManagedAgentRestartScript(manager, staged, binary, cleanLegacy)
|
||||||
|
if manager.kind == "systemd" {
|
||||||
|
// Wait only for submission, never for helper completion. A separate unit is
|
||||||
|
// essential: systemctl stop kills every process in the agent's cgroup.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, manager.runner, "--quiet", "/bin/sh", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("提交重启任务失败: %w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return startDetachedAgentHelper(script)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" }
|
||||||
|
|
||||||
|
func buildManagedAgentRestartScript(manager agentServiceManager, staged, binary string, cleanLegacy bool) string {
|
||||||
|
control := shellQuote(manager.control)
|
||||||
|
stop, start := control+" stop flux_agent", control+" start flux_agent"
|
||||||
|
if manager.kind == "openrc" {
|
||||||
|
stop, start = control+" flux_agent stop", control+" flux_agent start"
|
||||||
|
}
|
||||||
|
legacy := ""
|
||||||
|
if manager.kind == "systemd" && cleanLegacy {
|
||||||
|
legacy = `legacy_service=''; for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f "$service_file" ] && grep -Fq "WorkingDirectory=/etc/gost" "$service_file" && (grep -Fq "ExecStart=/etc/gost/gost" "$service_file" || (grep -Fq "ExecStart=/usr/local/bin/gost" "$service_file" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service="$service_file"; break; fi; done; if [ -n "$legacy_service" ]; then (` + control + ` stop gost 2>/dev/null || true); (` + control + ` disable gost 2>/dev/null || true); rm -f /usr/local/bin/gost /etc/gost/gost "$legacy_service"; (` + control + ` daemon-reload 2>/dev/null || true); fi; `
|
||||||
|
}
|
||||||
|
// The caller owns staging until launch. Afterwards the helper always removes
|
||||||
|
// it, including stop/replace failures. Never truncate the running executable.
|
||||||
|
return "trap " + shellQuote("rm -f -- "+shellQuote(staged)) + " EXIT; sleep 1; " + stop + " || exit $?; " + legacy +
|
||||||
|
"if [ ! -d " + shellQuote(binary) + " ] && mv -f -- " + shellQuote(staged) + " " + shellQuote(binary) + "; then " + start + "; else status=$?; " + start + "; exit \"$status\"; fi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to a unique file on the destination filesystem, then let the caller
|
||||||
|
// atomically rename. Failure never truncates a previous usable backup.
|
||||||
|
func stageAgentCopy(source, destination string) (path string, err error) {
|
||||||
|
in, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
info, err := in.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return "", fmt.Errorf("不是普通文件: %s", source)
|
||||||
|
}
|
||||||
|
out, err := os.CreateTemp(filepath.Dir(destination), ".flux-agent-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path = out.Name()
|
||||||
|
defer func() {
|
||||||
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err = io.Copy(out, in); err != nil {
|
||||||
|
return path, err
|
||||||
|
}
|
||||||
|
if err = out.Chmod(0755); err != nil {
|
||||||
|
return path, err
|
||||||
|
}
|
||||||
|
if err = out.Close(); err != nil {
|
||||||
|
return path, err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupAgentBinary(binary string) error {
|
||||||
|
staged, err := stageAgentCopy(binary, binary+".old")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(staged)
|
||||||
|
return os.Rename(staged, binary+".old")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkAgentDetach() error { return nil }
|
||||||
|
|
||||||
|
func startDetachedAgentHelper(script string) error {
|
||||||
|
cmd := exec.Command("/bin/sh", "-c", script)
|
||||||
|
// The installed OpenRC service stops the agent PID. A separate session also
|
||||||
|
// survives a process-group stop. No inherited stdio or parent-death signal.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() { _ = cmd.Wait() }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This subprocess stands in for the running agent. The rc-service double kills
|
||||||
|
// its entire process group, not merely a fake PID, while the real helper runs.
|
||||||
|
func TestAgentDetachedParent(t *testing.T) {
|
||||||
|
dir := os.Getenv("FLVX_TEST_DETACH_ROOT")
|
||||||
|
if dir == "" {
|
||||||
|
t.Skip("subprocess helper")
|
||||||
|
}
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
control := filepath.Join(dir, "rc-service")
|
||||||
|
writeAgentFixture(t, control, "#!/bin/sh\nif [ \"$2\" = stop ]; then kill -TERM -"+strconv.Itoa(os.Getpid())+"; else echo started > "+shellQuote(filepath.Join(dir, "started"))+"; fi\n")
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) { return agentServiceManager{kind: "openrc", control: control}, nil }}
|
||||||
|
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||||
|
if !response.Success {
|
||||||
|
t.Fatal(response.Message)
|
||||||
|
}
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
t.Fatal("original agent was not stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRCHelperSurvivesAgentProcessGroupStop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
writeAgentFixture(t, binary, "current")
|
||||||
|
writeAgentFixture(t, binary+".old", "backup")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAgentDetachedParent$", "-test.v")
|
||||||
|
cmd.Env = append(os.Environ(), "FLVX_TEST_DETACH_ROOT="+dir)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil || ctx.Err() != nil {
|
||||||
|
t.Fatalf("parent termination = %v, timeout=%v, %s", err, ctx.Err(), output)
|
||||||
|
}
|
||||||
|
exit, ok := err.(*exec.ExitError)
|
||||||
|
if !ok || exit.Sys().(syscall.WaitStatus).Signal() != syscall.SIGTERM {
|
||||||
|
t.Fatalf("parent did not receive SIGTERM: %v %s", err, output)
|
||||||
|
}
|
||||||
|
waitAgentFixture(t, filepath.Join(dir, "started"), "started\n")
|
||||||
|
if readAgentFixture(t, binary) != "backup" || readAgentFixture(t, binary+".old") != "backup" {
|
||||||
|
t.Fatal("rollback failed after parent termination")
|
||||||
|
}
|
||||||
|
fmt.Println("detached OpenRC helper survived original agent process-group SIGTERM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run only in an explicitly opted-in disposable Alpine container. The docker
|
||||||
|
// invocation mounts only the test binary, never a host /bin directory. Removing
|
||||||
|
// the container's /bin/sh symlink forces the real detached cmd.Start to fail.
|
||||||
|
func TestOpenRCCommandsCleanStageOnHelperExecFailure(t *testing.T) {
|
||||||
|
if os.Getenv("FLVX_TEST_DISPOSABLE_ALPINE_SHELL_FAILURE") != "1" {
|
||||||
|
t.Skip("requires explicit disposable Alpine container opt-in")
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/etc/alpine-release", "/.dockerenv"} {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("refusing shell mutation outside disposable Alpine: %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Lstat("/bin/sh")
|
||||||
|
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Fatalf("refusing to rename a non-symlink /bin/sh: %v", err)
|
||||||
|
}
|
||||||
|
saved := "/bin/.flvx-test-sh-" + strconv.Itoa(os.Getpid())
|
||||||
|
if _, err := os.Lstat(saved); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("refusing to overwrite shell backup: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename("/bin/sh", saved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Rename(saved, "/bin/sh"); err != nil {
|
||||||
|
t.Errorf("restore container shell: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
|
||||||
|
t.Run(operation, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
writeAgentFixture(t, binary, "current")
|
||||||
|
writeAgentFixture(t, binary+".old", "prior backup")
|
||||||
|
writeAgentFixture(t, filepath.Join(dir, "rc-service"), "#!/bin/sh\nexit 0\n")
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
// No manager or launcher injection: Alpine detection and exec.LookPath are
|
||||||
|
// production code too. Only the installation path points to an isolated dir.
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary}
|
||||||
|
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/checksum" {
|
||||||
|
fmt.Fprint(w, abcSHA256)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, "abc")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer fixture.Close()
|
||||||
|
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||||
|
if response.Success || !strings.Contains(response.Message, "/bin/sh") || !strings.Contains(response.Message, "no such file") {
|
||||||
|
t.Fatalf("expected real helper exec failure, got %+v", response)
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary) != "current" {
|
||||||
|
t.Fatal("live binary changed")
|
||||||
|
}
|
||||||
|
backup := "prior backup"
|
||||||
|
if operation == "UpgradeAgent" {
|
||||||
|
backup = "current"
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary+".old") != backup {
|
||||||
|
t.Fatal("unexpected backup contents")
|
||||||
|
}
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
|
||||||
|
if err != nil || len(matches) != 0 {
|
||||||
|
t.Fatalf("rollback staging residue: %v %v", matches, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("upgrade staging residue: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package socket
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func checkAgentDetach() error { return fmt.Errorf("OpenRC Agent 重启仅支持 Linux") }
|
||||||
|
func startDetachedAgentHelper(string) error { return checkAgentDetach() }
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func routeAgentOperation(t *testing.T, reporter *WebSocketReporter, operation string, data interface{}) CommandResponse {
|
||||||
|
t.Helper()
|
||||||
|
peer := make(chan *websocket.Conn, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||||
|
if err == nil {
|
||||||
|
peer <- c
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
reader := <-peer
|
||||||
|
defer reader.Close()
|
||||||
|
reporter.ctx = context.Background()
|
||||||
|
reporter.conn = c
|
||||||
|
reporter.connected = true
|
||||||
|
reporter.routeCommand(CommandMessage{Type: operation, RequestId: "restart-request", Data: data})
|
||||||
|
reader.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
for {
|
||||||
|
var response CommandResponse
|
||||||
|
if err := reader.ReadJSON(&response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Type == operation+"Response" {
|
||||||
|
if response.RequestId != "restart-request" {
|
||||||
|
t.Fatalf("request id lost: %s", response.RequestId)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackAgentStagesBackupBeforeRestart(t *testing.T) {
|
||||||
|
binary := filepath.Join(t.TempDir(), "flux_agent")
|
||||||
|
for path, body := range map[string]string{binary: "current", binary + ".old": "backup"} {
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(staged, dest string) error {
|
||||||
|
called = true
|
||||||
|
body, err := os.ReadFile(staged)
|
||||||
|
if err != nil || string(body) != "backup" || staged == binary+".old" || dest != binary {
|
||||||
|
t.Fatalf("bad rollback staging: %s %s %q %v", staged, dest, body, err)
|
||||||
|
}
|
||||||
|
return os.Rename(staged, dest)
|
||||||
|
}}
|
||||||
|
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||||
|
if !response.Success || !called {
|
||||||
|
t.Fatalf("rollback failed: called=%v %s", called, response.Message)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(binary + ".old")
|
||||||
|
if err != nil || string(body) != "backup" {
|
||||||
|
t.Fatalf("backup consumed: %q %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentServiceManagerDetection(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
alpine, systemd bool
|
||||||
|
tools string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"alpine shim", true, true, "rc-service systemctl systemd-run", "openrc"},
|
||||||
|
{"alpine missing openrc", true, true, "systemctl systemd-run", ""},
|
||||||
|
{"systemd", false, true, "rc-service systemctl systemd-run", "systemd"},
|
||||||
|
{"missing runner", false, true, "systemctl", ""},
|
||||||
|
{"openrc fallback", false, false, "rc-service", "openrc"},
|
||||||
|
{"unmanaged", false, false, "systemctl systemd-run", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
manager, err := detectAgentServiceManager(tc.alpine, tc.systemd, func(name string) (string, error) {
|
||||||
|
if strings.Contains(" "+tc.tools+" ", " "+name+" ") {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
})
|
||||||
|
if manager.kind != tc.want || (err != nil) != (tc.want == "") {
|
||||||
|
t.Fatalf("manager=%+v err=%v", manager, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAgentFixture(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func readAgentFixture(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
func waitAgentFixture(t *testing.T, path, want string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
body, _ := os.ReadFile(path)
|
||||||
|
if string(body) == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(path)
|
||||||
|
t.Fatalf("%s = %q, want %q", path, body, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentCommandsManagerLaunch(t *testing.T) {
|
||||||
|
for _, operation := range []string{"UpgradeAgent", "RollbackAgent"} {
|
||||||
|
for _, mode := range []string{"systemd", "openrc", "missing", "rejected"} {
|
||||||
|
t.Run(operation+"/"+mode, func(t *testing.T) {
|
||||||
|
if mode == "openrc" && runtime.GOOS != "linux" {
|
||||||
|
t.Skip("detached launcher is Linux-only")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "flux 'agent")
|
||||||
|
log := filepath.Join(dir, "events")
|
||||||
|
writeAgentFixture(t, binary, "current")
|
||||||
|
writeAgentFixture(t, binary+".old", "backup")
|
||||||
|
control := filepath.Join(dir, "manager")
|
||||||
|
writeAgentFixture(t, control, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> "+shellQuote(log)+"\n")
|
||||||
|
runner := filepath.Join(dir, "runner")
|
||||||
|
writeAgentFixture(t, runner, "#!/bin/sh\nshift\n\"$@\" >/dev/null 2>&1 &\n")
|
||||||
|
if mode == "rejected" {
|
||||||
|
writeAgentFixture(t, runner, "#!/bin/sh\necho rejected >&2\nexit 9\n")
|
||||||
|
}
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary, detectAgentManager: func() (agentServiceManager, error) {
|
||||||
|
return detectAgentServiceManager(mode == "openrc", mode != "missing", func(name string) (string, error) {
|
||||||
|
if mode == "missing" {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
if name == "systemd-run" {
|
||||||
|
return runner, nil
|
||||||
|
}
|
||||||
|
return control, nil
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/checksum" {
|
||||||
|
fmt.Fprint(w, abcSHA256)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, "abc")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer fixture.Close()
|
||||||
|
response := routeAgentOperation(t, reporter, operation, map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||||
|
if mode == "missing" || mode == "rejected" {
|
||||||
|
if response.Success {
|
||||||
|
t.Fatal("accepted failed manager launch")
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary) != "current" {
|
||||||
|
t.Fatal("live binary mutated")
|
||||||
|
}
|
||||||
|
if mode == "missing" && readAgentFixture(t, binary+".old") != "backup" {
|
||||||
|
t.Fatal("backup mutated before manager validation")
|
||||||
|
}
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(dir, ".flux-agent-*"))
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("staging residue %v", matches)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("upgrade residue")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !response.Success {
|
||||||
|
t.Fatal(response.Message)
|
||||||
|
}
|
||||||
|
want, backup := "abc", "current"
|
||||||
|
if operation == "RollbackAgent" {
|
||||||
|
want, backup = "backup", "backup"
|
||||||
|
}
|
||||||
|
waitAgentFixture(t, binary, want)
|
||||||
|
events := "stop flux_agent\nstart flux_agent\n"
|
||||||
|
if mode == "openrc" {
|
||||||
|
events = "flux_agent stop\nflux_agent start\n"
|
||||||
|
}
|
||||||
|
waitAgentFixture(t, log, events)
|
||||||
|
if readAgentFixture(t, binary+".old") != backup {
|
||||||
|
t.Fatal("wrong backup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRestartScriptFailureOrdering(t *testing.T) {
|
||||||
|
for _, mode := range []string{"stop", "replace"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
staged := filepath.Join(dir, "staged")
|
||||||
|
log := filepath.Join(dir, "events")
|
||||||
|
writeAgentFixture(t, binary, "current")
|
||||||
|
writeAgentFixture(t, staged, "replacement")
|
||||||
|
control := filepath.Join(dir, "rc-service")
|
||||||
|
script := "#!/bin/sh\necho \"$*\" >> " + shellQuote(log) + "\n"
|
||||||
|
if mode == "stop" {
|
||||||
|
script += "[ \"$2\" != stop ]\n"
|
||||||
|
}
|
||||||
|
writeAgentFixture(t, control, script)
|
||||||
|
if mode == "replace" {
|
||||||
|
writeAgentFixture(t, filepath.Join(dir, "mv"), "#!/bin/sh\nexit 23\n")
|
||||||
|
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
cmd := exec.Command("/bin/sh", "-c", buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: control}, staged, binary, true))
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
t.Fatal("expected failed stop/replacement")
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary) != "current" {
|
||||||
|
t.Fatal("changed binary after failure")
|
||||||
|
}
|
||||||
|
want := "flux_agent stop\n"
|
||||||
|
if mode == "replace" {
|
||||||
|
want += "flux_agent start\n"
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, log) != want {
|
||||||
|
t.Fatalf("wrong sequence %q", readAgentFixture(t, log))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(staged); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("staging residue")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentBackupFailurePreservesExistingBackup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
writeAgentFixture(t, binary+".old", "prior")
|
||||||
|
called := false
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
|
||||||
|
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/checksum" {
|
||||||
|
fmt.Fprint(w, abcSHA256)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, "abc")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer fixture.Close()
|
||||||
|
response := routeAgentOperation(t, reporter, "UpgradeAgent", map[string]string{"downloadUrl": fixture.URL + "/binary", "checksumUrl": fixture.URL + "/checksum"})
|
||||||
|
if response.Success || called {
|
||||||
|
t.Fatal("upgrade accepted without backup")
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary+".old") != "prior" {
|
||||||
|
t.Fatal("backup lost")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("staging residue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackAgentRejectsDirectoryDestination(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
if err := os.Mkdir(binary, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeAgentFixture(t, binary+".old", "backup")
|
||||||
|
called := false
|
||||||
|
reporter := &WebSocketReporter{upgradeBinaryPath: binary, startUpgradeRestart: func(string, string) error { called = true; return nil }}
|
||||||
|
response := routeAgentOperation(t, reporter, "RollbackAgent", nil)
|
||||||
|
if response.Success || called {
|
||||||
|
t.Fatal("accepted directory destination")
|
||||||
|
}
|
||||||
|
if readAgentFixture(t, binary+".old") != "backup" {
|
||||||
|
t.Fatal("changed backup")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(binary)
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("destination changed: %v %v", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRestartScriptRejectsDirectoryDestination(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "agent")
|
||||||
|
staged := filepath.Join(dir, "stage")
|
||||||
|
if err := os.Mkdir(binary, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeAgentFixture(t, staged, "replacement")
|
||||||
|
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "/usr/bin/true"}, staged, binary, false)
|
||||||
|
if err := exec.Command("/bin/sh", "-c", script).Run(); err == nil {
|
||||||
|
t.Fatal("accepted directory destination")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(binary)
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("binary moved inside directory: %v %v", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRCRestartNeverCleansSystemdLegacyService(t *testing.T) {
|
||||||
|
script := buildManagedAgentRestartScript(agentServiceManager{kind: "openrc", control: "rc-service"}, "staged", "binary", true)
|
||||||
|
for _, forbidden := range []string{"systemctl", "gost", "daemon-reload", "/etc/systemd"} {
|
||||||
|
if strings.Contains(script, forbidden) {
|
||||||
|
t.Fatalf("OpenRC helper contains %s: %s", forbidden, script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All writes are confined to TempDir; the restart callback never starts a process.
|
||||||
|
func exerciseUpgrade(t *testing.T, checksum string, serveChecksum http.HandlerFunc, valid bool) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
binary := filepath.Join(dir, "flux_agent")
|
||||||
|
for path, body := range map[string]string{binary: "old binary", binary + ".old": "prior backup"} {
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloads := 0
|
||||||
|
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/binary-"+runtime.GOARCH {
|
||||||
|
downloads++
|
||||||
|
fmt.Fprint(w, "abc")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/checksum-"+runtime.GOARCH {
|
||||||
|
t.Errorf("unexpected checksum path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveChecksum(w, r)
|
||||||
|
}))
|
||||||
|
defer fixture.Close()
|
||||||
|
if checksum == "fixture" {
|
||||||
|
checksum = fixture.URL + "/checksum-{ARCH}"
|
||||||
|
}
|
||||||
|
peer := make(chan *websocket.Conn, 1)
|
||||||
|
ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer <- c
|
||||||
|
}))
|
||||||
|
defer ws.Close()
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ws.URL, "http"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
reader := <-peer
|
||||||
|
defer reader.Close()
|
||||||
|
restarted := false
|
||||||
|
reporter := &WebSocketReporter{ctx: context.Background(), conn: conn, connected: true, upgradeBinaryPath: binary,
|
||||||
|
startUpgradeRestart: func(staged, destination string) error {
|
||||||
|
restarted = true
|
||||||
|
if destination != binary {
|
||||||
|
t.Errorf("wrong restart destination: %s", destination)
|
||||||
|
}
|
||||||
|
b, e := os.ReadFile(staged)
|
||||||
|
if e != nil || string(b) != "abc" {
|
||||||
|
t.Errorf("staged binary = %q, %v", b, e)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reporter.routeCommand(CommandMessage{Type: "UpgradeAgent", RequestId: "upgrade-test-request", Data: map[string]any{"downloadUrl": fixture.URL + "/binary-{ARCH}", "checksumUrl": checksum}})
|
||||||
|
reader.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
verified, installing := false, false
|
||||||
|
for {
|
||||||
|
var response CommandResponse
|
||||||
|
if err := reader.ReadJSON(&response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Type == "UpgradeAgentResponse" {
|
||||||
|
if response.RequestId != "upgrade-test-request" {
|
||||||
|
t.Errorf("response request ID = %q", response.RequestId)
|
||||||
|
}
|
||||||
|
if response.Success != valid {
|
||||||
|
t.Errorf("success = %v, want %v: %s", response.Success, valid, response.Message)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if response.Type == "UpgradeProgress" {
|
||||||
|
data := response.Data.(map[string]interface{})
|
||||||
|
if data["stage"] == "verifying" && data["percent"] == float64(100) {
|
||||||
|
verified = true
|
||||||
|
}
|
||||||
|
if data["stage"] == "installing" {
|
||||||
|
installing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restarted != valid || verified != valid || installing != valid {
|
||||||
|
t.Errorf("restart=%v verified=%v installing=%v, want %v", restarted, verified, installing, valid)
|
||||||
|
}
|
||||||
|
b, e := os.ReadFile(binary)
|
||||||
|
if e != nil || string(b) != "old binary" {
|
||||||
|
t.Errorf("old binary changed: %q %v", b, e)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
b, e = os.ReadFile(binary + ".old")
|
||||||
|
if e != nil || string(b) != "prior backup" {
|
||||||
|
t.Errorf("backup changed: %q %v", b, e)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(binary + ".new"); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("staged binary remains: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(checksum) == "" && downloads != 0 {
|
||||||
|
t.Errorf("downloaded without checksum URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRequiresChecksumURL(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "", func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected checksum request") }, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsChecksumHTTPFailure(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusNotFound) }, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const abcSHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsAmbiguousChecksum(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, abcSHA256+" gost-amd64\n"+abcSHA256+" gost-arm64\n")
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsOversizedChecksum(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, abcSHA256+" "+strings.Repeat("a", 4096)) }, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentChecksumRecords(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"bare", abcSHA256, true},
|
||||||
|
{"GNU text", abcSHA256 + " gost-arm64\n", true},
|
||||||
|
{"GNU binary", abcSHA256 + " *gost-arm64\n", true},
|
||||||
|
{"uppercase CRLF surrounding whitespace", " \t" + strings.ToUpper(abcSHA256) + " gost-arm64\r\n", true},
|
||||||
|
{"bare surrounding whitespace", "\r\n\t" + abcSHA256 + " \r\n", true},
|
||||||
|
{"empty", "", false},
|
||||||
|
{"whitespace", " \r\n\t", false},
|
||||||
|
{"truncated", abcSHA256[:63], false},
|
||||||
|
{"nonhex", "z" + abcSHA256[1:], false},
|
||||||
|
{"mismatch", strings.Repeat("0", 64), false},
|
||||||
|
{"missing GNU marker", abcSHA256 + " gost-arm64", false},
|
||||||
|
{"invalid GNU marker", abcSHA256 + " ?gost-arm64", false},
|
||||||
|
{"missing filename", abcSHA256 + " *", false},
|
||||||
|
{"appended garbage", abcSHA256 + "garbage", false},
|
||||||
|
{"embedded CR", abcSHA256 + " gost\rarm64", false},
|
||||||
|
{"NUL", abcSHA256 + " gost\x00arm64", false},
|
||||||
|
{"two bare digests", abcSHA256 + "\n" + abcSHA256, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, tc.body) }, tc.valid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsChecksumTransportFailure(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "://invalid", nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsChecksumReadFailure(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// A valid-looking record is incomplete relative to the declared body length.
|
||||||
|
w.Header().Set("Content-Length", "999")
|
||||||
|
fmt.Fprint(w, abcSHA256)
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsBlankChecksumURL(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, " \t\r\n", nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeAgentRejectsChecksumConnectionFailure(t *testing.T) {
|
||||||
|
exerciseUpgrade(t, "fixture", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, _, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -158,6 +157,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type WebSocketReporter struct {
|
type WebSocketReporter struct {
|
||||||
|
// Per-reporter upgrade dependencies keep staging and process launch isolated.
|
||||||
|
upgradeBinaryPath string
|
||||||
|
startUpgradeRestart func(string, string) error
|
||||||
|
detectAgentManager func() (agentServiceManager, error)
|
||||||
|
|
||||||
url string
|
url string
|
||||||
addr string // 保存服务器地址
|
addr string // 保存服务器地址
|
||||||
secret string // 保存密钥
|
secret string // 保存密钥
|
||||||
@@ -1377,6 +1381,23 @@ func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, messa
|
|||||||
w.sendResponse(response)
|
w.sendResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseUpgradeChecksum accepts one bare SHA256 or one GNU sha256sum record.
|
||||||
|
// A manifest cannot safely identify the requested binary by its first record.
|
||||||
|
func parseUpgradeChecksum(body []byte) (string, error) {
|
||||||
|
record := strings.TrimSpace(string(body))
|
||||||
|
if len(record) < 64 || strings.ContainsAny(record, "\r\n\x00") {
|
||||||
|
return "", fmt.Errorf("校验文件格式无效: 需要单条 SHA256 记录")
|
||||||
|
}
|
||||||
|
digest := record[:64]
|
||||||
|
if _, err := hex.DecodeString(digest); err != nil {
|
||||||
|
return "", fmt.Errorf("校验文件格式无效: SHA256 必须为 64 位十六进制")
|
||||||
|
}
|
||||||
|
if len(record) != 64 && (len(record) < 67 || record[64] != ' ' || (record[65] != ' ' && record[65] != '*') || strings.TrimSpace(record[66:]) == "") {
|
||||||
|
return "", fmt.Errorf("校验文件格式无效: 需要单条 GNU SHA256 记录")
|
||||||
|
}
|
||||||
|
return digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||||
jsonData, err := json.Marshal(data)
|
jsonData, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1394,6 +1415,10 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
|||||||
return fmt.Errorf("下载地址不能为空")
|
return fmt.Errorf("下载地址不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.ChecksumURL) == "" {
|
||||||
|
return fmt.Errorf("校验文件地址不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
// 替换架构占位符
|
// 替换架构占位符
|
||||||
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
||||||
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
||||||
@@ -1402,9 +1427,11 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
|||||||
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
||||||
|
|
||||||
// 下载新版本二进制
|
// 下载新版本二进制
|
||||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
binaryPath := w.upgradeBinaryPath
|
||||||
|
if binaryPath == "" {
|
||||||
|
binaryPath = "/etc/flux_agent/flux_agent"
|
||||||
|
}
|
||||||
tmpPath := binaryPath + ".new"
|
tmpPath := binaryPath + ".new"
|
||||||
backupPath := binaryPath + ".old"
|
|
||||||
|
|
||||||
resp, err := http.Get(downloadURL)
|
resp, err := http.Get(downloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1421,6 +1448,14 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
|||||||
return fmt.Errorf("创建临时文件失败: %v", err)
|
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The restart process owns the staged file only after launch succeeds.
|
||||||
|
restartStarted := false
|
||||||
|
defer func() {
|
||||||
|
if !restartStarted {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 带进度的下载
|
// 带进度的下载
|
||||||
totalSize := resp.ContentLength
|
totalSize := resp.ContentLength
|
||||||
var downloaded int64
|
var downloaded int64
|
||||||
@@ -1433,7 +1468,6 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
|||||||
if n > 0 {
|
if n > 0 {
|
||||||
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
||||||
outFile.Close()
|
outFile.Close()
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("写入升级包失败: %v", wErr)
|
return fmt.Errorf("写入升级包失败: %v", wErr)
|
||||||
}
|
}
|
||||||
hasher.Write(buf[:n])
|
hasher.Write(buf[:n])
|
||||||
@@ -1451,104 +1485,97 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
outFile.Close()
|
outFile.Close()
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("读取升级包失败: %v", readErr)
|
return fmt.Errorf("读取升级包失败: %v", readErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outFile.Close()
|
if err := outFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("关闭升级包失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if downloaded == 0 {
|
if downloaded == 0 {
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("下载的升级包为空")
|
return fmt.Errorf("下载的升级包为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
||||||
|
|
||||||
// Checksum 校验
|
// Every checksum failure must abort before installation.
|
||||||
if checksumURL != "" {
|
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||||
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
checksumResp, err := http.Get(checksumURL)
|
||||||
checksumResp, err := http.Get(checksumURL)
|
if err != nil {
|
||||||
if err == nil {
|
return fmt.Errorf("下载校验文件失败: %w", err)
|
||||||
defer checksumResp.Body.Close()
|
|
||||||
if checksumResp.StatusCode == http.StatusOK {
|
|
||||||
checksumBody, err := io.ReadAll(checksumResp.Body)
|
|
||||||
if err == nil {
|
|
||||||
// 格式: "<hash> <filename>" 或 "<hash>"
|
|
||||||
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
|
|
||||||
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
|
||||||
if !strings.EqualFold(expectedHash, actualHash) {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
|
||||||
}
|
|
||||||
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
|
||||||
}
|
}
|
||||||
|
defer checksumResp.Body.Close()
|
||||||
|
if checksumResp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载校验文件失败, HTTP状态码: %d", checksumResp.StatusCode)
|
||||||
|
}
|
||||||
|
const maxChecksumBytes = 4096
|
||||||
|
checksumBody, err := io.ReadAll(io.LimitReader(checksumResp.Body, maxChecksumBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取校验文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(checksumBody) > maxChecksumBytes {
|
||||||
|
return fmt.Errorf("校验文件过大: 最大 %d bytes", maxChecksumBytes)
|
||||||
|
}
|
||||||
|
expectedHash, err := parseUpgradeChecksum(checksumBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
if !strings.EqualFold(expectedHash, actualHash) {
|
||||||
|
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||||
|
}
|
||||||
|
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||||
|
|
||||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("设置执行权限失败: %v", err)
|
return fmt.Errorf("设置执行权限失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备份旧版本
|
// Resolve the manager before mutating an existing rollback backup.
|
||||||
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
startRestart, err := w.agentRestartLauncher(true)
|
||||||
if _, err := os.Stat(binaryPath); err == nil {
|
if err != nil {
|
||||||
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
|
return err
|
||||||
oldData, err := os.ReadFile(binaryPath)
|
}
|
||||||
if err == nil {
|
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||||
_ = os.WriteFile(backupPath, oldData, 0755)
|
if err := backupAgentBinary(binaryPath); err != nil {
|
||||||
fmt.Println("📦 旧版本已备份到", backupPath)
|
return fmt.Errorf("备份旧版本失败: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
||||||
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
|
if err := startRestart(tmpPath, binaryPath); err != nil {
|
||||||
|
|
||||||
// 执行重启脚本
|
|
||||||
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
|
||||||
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
|
||||||
script := buildAgentRestartScript(tmpPath, binaryPath)
|
|
||||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
return fmt.Errorf("启动重启脚本失败: %v", err)
|
return fmt.Errorf("启动重启脚本失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restartStarted = true
|
||||||
w.sendUpgradeProgress("installing", 100, "重启中...")
|
w.sendUpgradeProgress("installing", 100, "重启中...")
|
||||||
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
binaryPath := w.upgradeBinaryPath
|
||||||
backupPath := binaryPath + ".old"
|
if binaryPath == "" {
|
||||||
|
binaryPath = "/etc/flux_agent/flux_agent"
|
||||||
// 检查备份文件是否存在
|
|
||||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("没有可用的备份文件,无法回退")
|
|
||||||
}
|
}
|
||||||
|
startRestart, err := w.agentRestartLauncher(false)
|
||||||
fmt.Println("🔄 开始回退到旧版本...")
|
if err != nil {
|
||||||
|
return err
|
||||||
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
|
}
|
||||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
|
if info, err := os.Stat(binaryPath); err != nil || !info.Mode().IsRegular() {
|
||||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
return fmt.Errorf("回退目标不是可用的普通文件: %s", binaryPath)
|
||||||
if err := cmd.Start(); err != nil {
|
}
|
||||||
return fmt.Errorf("启动回退脚本失败: %v", err)
|
staged, err := stageAgentCopy(binaryPath+".old", binaryPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("准备回退备份失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := startRestart(staged, binaryPath); err != nil {
|
||||||
|
_ = os.Remove(staged)
|
||||||
|
return fmt.Errorf("启动回退脚本失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAgentRestartScript(tmpPath, binaryPath string) string {
|
func buildAgentRestartScript(tmpPath, binaryPath string) string {
|
||||||
return fmt.Sprintf(
|
return buildManagedAgentRestartScript(agentServiceManager{kind: "systemd", control: "systemctl"}, tmpPath, binaryPath, true)
|
||||||
"sleep 1 && systemctl stop flux_agent && legacy_service='' && for service_file in /etc/systemd/system/gost.service /lib/systemd/system/gost.service /usr/lib/systemd/system/gost.service; do if [ -f \"$service_file\" ] && grep -Fq \"WorkingDirectory=/etc/gost\" \"$service_file\" && (grep -Fq \"ExecStart=/etc/gost/gost\" \"$service_file\" || (grep -Fq \"ExecStart=/usr/local/bin/gost\" \"$service_file\" && [ -f /etc/gost/config.json ] && [ -f /etc/gost/gost.json ])); then legacy_service=\"$service_file\"; break; fi; done && if [ -n \"$legacy_service\" ]; then (systemctl stop gost 2>/dev/null || true) && (systemctl disable gost 2>/dev/null || true) && rm -f /usr/local/bin/gost /etc/gost/gost \"$legacy_service\" && (systemctl daemon-reload 2>/dev/null || true); fi && mv %s %s && systemctl start flux_agent",
|
|
||||||
tmpPath,
|
|
||||||
binaryPath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user