Compare commits

..

1 Commits

Author SHA1 Message Date
sagitchu d4579b8af5 fix(frontend): constrain service monitor records modal 2026-09-24 16:09:14 +08:00
94 changed files with 694 additions and 8194 deletions
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
node-version: '20.19.0'
- name: Install pnpm
run: npm install -g pnpm@10.28.1
@@ -54,7 +54,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'go-backend/go.mod'
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -89,7 +89,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'go-backend/go.mod'
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -112,7 +112,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'go-gost/go.mod'
go-version: '1.23'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go-gost/go.mod'
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
@@ -197,7 +197,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go-backend/go.mod'
go-version: '1.23'
- name: Cache Go dependencies
uses: actions/cache@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Get version from tag
-87
View File
@@ -1,87 +0,0 @@
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" .
-1
View File
@@ -1 +0,0 @@
22.23.4
+1 -2
View File
@@ -40,8 +40,7 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
- **Go versions**: 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).
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
## Anti-patterns
-45
View File
@@ -17,12 +17,6 @@
- 支持隧道修改配置、转发修改隧道
## 构建环境
Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27,须为 **1.27.3 或更新补丁版**。前端与 skill 发布使用 **Node.js 22.23.4**(见 `.node-version`),本地也支持 24.22.1+ 或 26.12.1+。前端使用 `pnpm@10.28.1`。
这些版本包含 2026 年 10 月的运行时安全修复。已有部署还需重新构建并更新后端镜像及所有 agent 二进制,详见 [安全排查与升级说明](docs/security/2026-10-runtime-updates.md)。
## 部署流程
---
### Docker Compose部署
@@ -77,45 +71,6 @@ docker compose up -d
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 通行证密钥登录(可选)
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
```dotenv
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
```
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
```yaml
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
```
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
**入口未显示时,按顺序检查:**
1. 在部署目录运行 `docker compose exec backend printenv FLVX_WEBAUTHN_ORIGIN`,仅检查这一项是否为预期的公开前端 origin;不要贴出完整 `docker compose config` 或其他环境变量,以免泄露密码和密钥。若为空,检查 `.env` 和实际 Compose 模板的 `backend.environment`,然后按步骤 2 重建后端。
2. 向浏览器实际调用的后端地址发送公开状态请求,例如将以下示例地址替换为自己的 API 地址后运行 `curl -sS -X POST 'https://api.example.com/api/v1/user/passkey/status' -H 'Content-Type: application/json' -d '{}'`。应返回 `code: 0` 且 `data.enabled: true`;若为 `false`,后端尚未接受有效配置。此接口无需登录或提供密码。
3. 在访问面板的浏览器控制台检查 `window.isSecureContext` 为 `true`,且 `typeof window.PublicKeyCredential` 不是 `"undefined"`。普通公网 HTTP 页面或不支持 WebAuthn 的浏览器不会显示入口。确认后刷新页面,并查看 `/api/v1/user/passkey/status` 的实际网络响应。
4. 若状态已启用且浏览器满足条件,检查 `docker compose images frontend` 中的前端镜像版本,并按步骤 2 的 PWA 提示刷新或使用无痕窗口。登录页按钮与个人资料页卡片均不存在时,通常是旧前端页面或状态请求未成功;只有其中一个缺失时,记录页面地址和该请求的响应再排查。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人资料页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
建议保留至少一种可用的密码恢复途径。数据库级备份会保留密钥记录;当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
-1
View File
@@ -13,7 +13,6 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
-1
View File
@@ -13,7 +13,6 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
-37
View File
@@ -1,37 +0,0 @@
# 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)
-133
View File
@@ -1,133 +0,0 @@
# 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 -2
View File
@@ -1,5 +1,4 @@
# CVE-2026-111943: crypto/x509 is linked into paneld; rebuild with the fixed Go release.
FROM golang:1.26.10-bookworm AS builder
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
+2 -9
View File
@@ -1,15 +1,13 @@
module go-backend
go 1.26.10
go 1.25.0
require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.42.0
golang.org/x/crypto v0.31.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -17,19 +15,14 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+2 -20
View File
@@ -3,20 +3,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -37,8 +27,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -50,12 +38,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -65,8 +49,6 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
@@ -534,22 +534,7 @@ func buildForwardServiceDeleteNames(bases []string) []string {
return names
}
type forwardServiceControlOptions struct {
tolerateNotFound bool
tolerateOffline bool
allowSelfHeal bool
continueOnError bool
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
return h.controlForwardServicesWithOptions(forward, commandType, forwardServiceControlOptions{
tolerateNotFound: tolerateNotFound,
tolerateOffline: true,
allowSelfHeal: true,
})
}
func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, commandType string, opts forwardServiceControlOptions) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
}
@@ -576,7 +561,6 @@ func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, comm
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
var nodeErrors []error
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
seen := map[int64]struct{}{}
for _, fp := range ports {
@@ -585,20 +569,16 @@ func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, comm
}
seen[fp.NodeID] = struct{}{}
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
if isNodeOfflineOrTimeoutError(err) {
continue
}
if opts.tolerateNotFound && isNotFoundError(err) {
continue
}
if opts.continueOnError {
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
if tolerateNotFound && isNotFoundError(err) {
continue
}
return err
}
}
return errors.Join(nodeErrors...)
return nil
}
seen := map[int64]struct{}{}
healed := false
@@ -608,50 +588,37 @@ func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, comm
}
seen[fp.NodeID] = struct{}{}
nodeErr := func() error {
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err
}
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
return nil
}
return err
}
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
if nodeHandled || opts.tolerateNotFound {
return nil
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}()
if nodeErr != nil {
if !opts.continueOnError {
return nodeErr
}
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, nodeErr))
}
}
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)
if nodeHandled {
continue
}
if tolerateNotFound {
continue
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}
return fmt.Errorf("节点 %s (ID %d): %w", name, nodeID, err)
return nil
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
+153 -174
View File
@@ -1,11 +1,8 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
@@ -411,26 +408,9 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
share, err := h.repo.GetPeerShare(req.ID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if share != nil {
// Revoke new allocations before cleanup; failed deletions keep the
// disabled share and its reservations available for retry.
share.IsActive = 0
share.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShare(share); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
share, _ := h.repo.GetPeerShare(req.ID)
if err := h.cleanupPeerShareRuntimes(req.ID); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
h.cleanupPeerShareRuntimes(req.ID)
h.cleanupFederationTunnels(req.ID)
if err := h.repo.DeletePeerShare(req.ID); err != nil {
@@ -438,6 +418,10 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
if share != nil && h.wsServer != nil {
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -821,6 +805,16 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
return
}
if share.IsActive == 0 {
response.WriteJSON(w, response.Err(403, "Share is disabled"))
return
}
if share.ExpiryTime > 0 && share.ExpiryTime < time.Now().UnixMilli() {
response.WriteJSON(w, response.Err(403, "Share expired"))
return
}
if strings.TrimSpace(share.AllowedIPs) != "" {
clientIP := resolvePeerClientIP(r)
if clientIP == nil {
@@ -853,48 +847,10 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
}
}
if share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
if !isFederationCleanupRequest(r) {
response.WriteJSON(w, response.Err(403, "Share is inactive, expired, or over quota"))
return
}
}
next(w, r)
}
}
// Revoked allocation privileges must not revoke cleanup privileges. Inspect
// only supported destructive commands, preserving the body for the handler.
func isFederationCleanupRequest(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
switch r.URL.Path {
case "/api/v1/federation/runtime/release-role":
return true
case "/api/v1/federation/runtime/command":
if r.Body == nil {
return false
}
var copied bytes.Buffer
original := r.Body
var request federationRuntimeCommandRequest
err := json.NewDecoder(io.TeeReader(io.LimitReader(original, 1<<20), &copied)).Decode(&request)
r.Body = struct {
io.Reader
io.Closer
}{Reader: io.MultiReader(&copied, original), Closer: original}
if err != nil || !isFederationRuntimeCommandAllowed(request.CommandType) {
return false
}
_, action := federationResourceCommandKind(request.CommandType)
return action == "delete"
default:
return false
}
}
func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -1026,6 +982,8 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
"tunnelId": tunnelID,
}))
@@ -1055,28 +1013,11 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
return
}
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
response.WriteJSON(w, response.Err(403, "Share is unavailable"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
existing, err := h.repo.GetPeerShareRuntimeByResourceKey(share.ID, req.ResourceKey)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existing != nil && existing.ReleasePending != 0 {
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
return
}
if existing != nil && existing.Status == 1 {
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
@@ -1085,6 +1026,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
if err != nil {
@@ -1094,7 +1039,6 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
now := time.Now().UnixMilli()
if existing != nil {
existing.ReservationID = randomToken(24)
existing.Protocol = defaultString(req.Protocol, "tls")
existing.Port = allocatedPort
existing.BindingID = ""
@@ -1172,14 +1116,6 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
response.WriteJSON(w, response.ErrDefault("Share is unavailable"))
return
}
var runtime *repo.PeerShareRuntime
if strings.TrimSpace(req.ReservationID) != "" {
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
@@ -1195,37 +1131,108 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
if runtime.ReleasePending != 0 {
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
node, err := h.getNodeRecord(share.NodeID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
protocol := defaultString(req.Protocol, runtime.Protocol)
strategy := defaultString(req.Strategy, "round")
chainName := defaultString(runtime.ChainName, federationRuntimeChainName(runtime.BindingID))
if chainName == "" {
chainName = federationRuntimeChainName(fmt.Sprintf("%d", runtime.ID))
}
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
if runtime.Applied == 1 && strings.TrimSpace(runtime.BindingID) != "" {
if req.Role == "middle" && len(req.Targets) > 0 {
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
if buildErr != nil {
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
return
}
if _, err := h.sendNodeCommand(share.NodeID, "UpdateChains", updateChainPayload(chainName, chainData), false, false); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
targetBytes, _ := json.Marshal(req.Targets)
runtime.Role = req.Role
runtime.ChainName = chainName
runtime.Protocol = protocol
runtime.Strategy = strategy
runtime.Target = string(targetBytes)
runtime.Status = 1
runtime.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"bindingId": runtime.BindingID,
"allocatedPort": runtime.Port,
"reservationId": runtime.ReservationID,
}))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
if runtime.Role != "" && runtime.Role != req.Role {
response.WriteJSON(w, response.ErrDefault("Runtime role cannot change without release"))
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
return
}
}
if req.Role == "middle" {
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
if buildErr != nil {
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
return
}
if _, err := h.sendNodeCommand(share.NodeID, "AddChains", chainData, true, false); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
targetCount := len(req.Targets)
service := buildFederationServiceConfig(
serviceName,
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
protocol,
req.Role,
chainName,
targetCount,
node.InterfaceName,
)
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
if req.Role == "middle" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
}
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && (runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd) {
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
return
}
if strings.TrimSpace(runtime.BindingID) == "" {
runtime.BindingID = randomToken(24)
}
targetBytes, _ := json.Marshal(req.Targets)
runtime.BindingID = fmt.Sprintf("%d", runtime.ID)
runtime.Role = req.Role
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
runtime.ChainName = ""
if req.Role == "middle" {
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
runtime.ChainName = chainName
}
runtime.Protocol = defaultString(req.Protocol, runtime.Protocol)
runtime.Strategy = defaultString(req.Strategy, "round")
targetBytes, _ := json.Marshal(req.Targets)
runtime.ServiceName = serviceName
runtime.Protocol = protocol
runtime.Strategy = strategy
runtime.Target = string(targetBytes)
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
runtime.Applied = 1
runtime.Status = 1
runtime.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1275,8 +1282,17 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
return
}
if err := h.releasePeerShareRuntime(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
if runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
if err := h.repo.MarkPeerShareRuntimeReleased(runtime.ID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1369,15 +1385,6 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
return
}
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
// Recheck after acquiring the mutation lock: an earlier authentication
// decision cannot authorize a recreation after quota/expiry cleanup.
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil {
response.WriteJSON(w, response.ErrDefault("share ownership unavailable"))
return
}
if isFederationServiceCommand(cmd) {
if err := validateFederationCommandPorts(share, req.Data); err != nil {
response.WriteJSON(w, response.Err(403, err.Error()))
@@ -1385,35 +1392,17 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
}
}
_, action := federationResourceCommandKind(cmd)
if action != "delete" && (share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)) {
response.WriteJSON(w, response.Err(403, "share is inactive, expired, or over quota"))
return
}
if strings.EqualFold(cmd, "tcpping") {
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OK(res))
return
}
items, err := h.preparePeerResourceCommand(share, cmd, req.Data)
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
var result interface{} = map[string]interface{}{"success": true}
for _, item := range items {
res, err := h.applyPeerShareResource(item)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
result = res
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
} else if strings.EqualFold(cmd, "deleteservice") {
h.releasePeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(result))
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
@@ -1447,15 +1436,10 @@ func parseFederationForwardServiceBindings(data interface{}) []federationForward
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
originalName := name
if shareID, original, ok := parsePeerShareServiceName(asString(svcMap["name"])); ok {
originalName = normalizeForwardRuntimeServiceName(original)
name = peerShareResourceName(shareID, "service", originalName)
}
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(originalName); !ok {
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
@@ -1514,29 +1498,29 @@ func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
return out
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) error {
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return nil
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return nil
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil {
return err
continue
}
if runtime == nil {
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
if err != nil {
return err
continue
}
}
if runtime == nil {
if err := h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
@@ -1553,14 +1537,9 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
return err
}
})
continue
}
if runtime.ReleasePending != 0 {
return fmt.Errorf("runtime release is pending")
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
continue
}
@@ -1575,11 +1554,8 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
if strings.TrimSpace(runtime.Strategy) == "" {
runtime.Strategy = "fifo"
}
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
return err
}
_ = h.repo.UpdatePeerShareRuntime(runtime)
}
return nil
}
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
@@ -1599,7 +1575,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "updatechains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "addclimiters", "updateclimiters", "deleteclimiters", "tcpping":
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
return true
default:
return false
@@ -1917,24 +1893,27 @@ func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
}
}
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) error {
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return nil
}
if err := h.releasePeerShareResources(shareID); err != nil {
return err
return
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil {
return err
if err != nil || len(runtimes) == 0 {
return
}
var cleanupErr error
for i := range runtimes {
if err := h.releasePeerShareRuntime(&runtimes[i]); err != nil {
cleanupErr = errors.Join(cleanupErr, err)
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
return cleanupErr
}
func (h *Handler) cleanupFederationTunnels(shareID int64) {
@@ -1,275 +0,0 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
func consumerCleanupFixture(t *testing.T, remoteURL string) (*repo.Repository, *Handler) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "consumer-cleanup.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if err := r.DB().Create(&model.Node{ID: 1, Name: "remote", Secret: "remote", ServerIP: "127.0.0.1", Port: "31000-31010", IsRemote: 1, Status: 1, RemoteURL: sql.NullString{String: remoteURL, Valid: true}, RemoteToken: sql.NullString{String: "token", Valid: true}}).Error; err != nil {
t.Fatal(err)
}
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 42, NodeID: 1, ChainType: 3, RemoteURL: remoteURL, ResourceKey: "tunnel:42:node:1:type:3:hop:0", RemoteBindingID: "binding-old", AllocatedPort: 31001, Status: 1}); err != nil {
t.Fatal(err)
}
if err := r.DB().Create(&model.Tunnel{ID: 42, Name: "live tunnel", Type: 2, Protocol: "tls", Flow: 1, TrafficRatio: 1, Status: 1}).Error; err != nil {
t.Fatal(err)
}
return r, &Handler{repo: r, wsServer: ws.NewServer(r, "consumer-cleanup")}
}
func TestTunnelUpdateValidationPreservesSharedRuntime(t *testing.T) {
for _, body := range []string{
`{"id":42,"type":2,"name":"invalid update","inNodeId":[]}`,
`{"id":42,"type":2,"name":"invalid update","inNodeId":[{"nodeId":999}],"outNodeId":[{"nodeId":1,"port":31001}]}`,
} {
t.Run(body, func(t *testing.T) {
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, req)
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
bindings, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil {
t.Fatal(err)
}
name, err := r.GetTunnelName(42)
if err != nil {
t.Fatal(err)
}
if payload.Code == 0 || releases.Load() != 0 || len(bindings) != 1 || name != "live tunnel" {
t.Fatalf("invalid update changed runtime: code=%d releases=%d bindings=%v name=%s", payload.Code, releases.Load(), bindings, name)
}
})
}
}
func TestFederationCleanupRetainsFailedBindingAndRetriesOnlyPending(t *testing.T) {
var unavailable atomic.Bool
unavailable.Store(true)
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
if unavailable.Load() {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
return
}
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 43, NodeID: 1, ChainType: 3, RemoteURL: remote.URL, RemoteBindingID: "unrelated", ResourceKey: "unrelated", Status: 1}); err != nil {
t.Fatal(err)
}
if err := h.cleanupFederationRuntime(42); err == nil {
t.Fatal("expected release failure")
}
pending, err := r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 1 {
t.Fatalf("lost pending cleanup: %v %v", pending, err)
}
if err := h.cleanupFederationRuntime(42); err == nil {
t.Fatal("expected second release failure")
}
if releases.Load() != 2 {
t.Fatalf("cleanup was not retried: %d", releases.Load())
}
unavailable.Store(false)
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
t.Fatal(err)
}
pending, err = r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 0 {
t.Fatalf("completed cleanup remains: %v %v", pending, err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(43)
if err != nil || len(active) != 1 || releases.Load() != 3 {
t.Fatalf("retry touched active binding: %v %v calls=%d", active, err, releases.Load())
}
}
func TestTunnelDeleteReportsRemoteFailureAndKeepsTunnel(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
rec := httptest.NewRecorder()
h.tunnelDelete(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", strings.NewReader(`{"id":42}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Code == 0 {
t.Fatal("delete reported success before peer cleanup")
}
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
t.Fatalf("lost tunnel: %s %v", name, err)
}
pending, err := r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 1 {
t.Fatalf("lost binding: %v %v", pending, err)
}
}
func TestFederationRollbackReleaseIsDurableAndRetryable(t *testing.T) {
var unavailable atomic.Bool
unavailable.Store(true)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if unavailable.Load() {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
return
}
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
refs := []federationRuntimeReleaseRef{{RemoteURL: remote.URL, RemoteToken: "token", BindingID: "new-binding", ReservationID: "new-reservation", ResourceKey: "new-key"}}
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
t.Fatal("expected release error")
}
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
t.Fatal("expected repeat error")
}
pending, err := r.ListPendingFederationReleases()
if err != nil || len(pending) != 1 {
t.Fatalf("rollback queue lost or duplicated release: %v %v", pending, err)
}
unavailable.Store(false)
// A new Handler has no in-memory state from the failed rollback.
restarted := &Handler{repo: r}
if err := restarted.retryPendingFederationRuntimeCleanup(); err != nil {
t.Fatal(err)
}
pending, err = r.ListPendingFederationReleases()
if err != nil || len(pending) != 0 {
t.Fatalf("completed rollback remains queued: %v %v", pending, err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || len(active) != 1 {
t.Fatalf("rollback retry removed active binding: %v %v", active, err)
}
}
func TestFederationApplyFailureReturnsReservationForRollback(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "reserve-port") {
response.WriteJSON(w, response.OK(map[string]interface{}{"reservationId": "reserved", "bindingId": "binding", "allocatedPort": 31001}))
return
}
http.Error(w, "apply failed", http.StatusServiceUnavailable)
}))
defer remote.Close()
_, h := consumerCleanupFixture(t, remote.URL)
state := &tunnelCreateState{TunnelID: 43, Type: 2, OutNodes: []tunnelRuntimeNode{{NodeID: 1, Port: 31001}}, Nodes: map[int64]*nodeRecord{1: {ID: 1, Name: "remote", IsRemote: 1, RemoteURL: remote.URL, RemoteToken: "token"}}}
_, refs, err := h.applyFederationRuntime(state, "")
if err == nil || len(refs) != 1 || refs[0].ReservationID != "reserved" {
t.Fatalf("failed apply lost reservation: refs=%+v err=%v", refs, err)
}
}
func TestTunnelUpdateDatabaseValidationPreservesSharedRuntime(t *testing.T) {
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := r.DB().Exec(`CREATE TRIGGER reject_tunnel_update BEFORE UPDATE ON tunnel BEGIN SELECT RAISE(ABORT, 'test constraint rejected'); END`).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "test constraint rejected") || releases.Load() != 0 || len(active) != 1 {
t.Fatalf("DB validation touched runtime: payload=%+v bindings=%v calls=%d err=%v", payload, active, releases.Load(), err)
}
}
func TestTunnelUpdateRemoteReleaseFailurePreservesMetadata(t *testing.T) {
var requests atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requests.Add(1)
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
name, err := r.GetTunnelName(42)
pending, pendingErr := r.ListPendingFederationTunnelBindings()
if err != nil || pendingErr != nil || payload.Code == 0 || name != "live tunnel" || requests.Load() != 1 || len(pending) != 1 {
t.Fatalf("failed cleanup continued update: payload=%+v name=%s calls=%d pending=%v err=%v/%v", payload, name, requests.Load(), pending, err, pendingErr)
}
}
func TestTunnelUpdateChainWriteValidationPreservesSharedRuntime(t *testing.T) {
var calls atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
calls.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := r.DB().Exec(`CREATE TRIGGER reject_chain_insert BEFORE INSERT ON chain_tunnel BEGIN SELECT RAISE(ABORT, 'chain write rejected'); END`).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "chain write rejected") || calls.Load() != 0 || len(active) != 1 {
t.Fatalf("chain validation touched runtime: payload=%+v active=%v calls=%d err=%v", payload, active, calls.Load(), err)
}
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
t.Fatalf("preflight metadata update was committed: %s %v", name, err)
}
}
@@ -1,703 +0,0 @@
package handler
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
const peerShareResourcePrefix = "peer-share-"
func peerShareResourceName(shareID int64, kind, original string) string {
return fmt.Sprintf("%s%d-%s-%s", peerShareResourcePrefix, shareID, kind, base64.RawURLEncoding.EncodeToString([]byte(original)))
}
func parsePeerShareServiceName(name string) (shareID int64, originalName string, ok bool) {
if !strings.HasPrefix(name, peerShareResourcePrefix) {
return
}
parts := strings.SplitN(strings.TrimPrefix(name, peerShareResourcePrefix), "-", 3)
if len(parts) != 3 || parts[1] != "service" {
return
}
shareID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || shareID <= 0 {
return 0, "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(decoded) == 0 {
return 0, "", false
}
return shareID, string(decoded), true
}
func federationResourceCommandKind(cmd string) (kind, action string) {
lower := strings.ToLower(cmd)
for _, entry := range []struct{ suffix, kind string }{{"service", "service"}, {"chains", "chain"}, {"climiters", "climiter"}, {"limiters", "limiter"}} {
if strings.HasSuffix(lower, entry.suffix) {
return entry.kind, strings.TrimSuffix(lower, entry.suffix)
}
}
return "", ""
}
// Every peer-supplied reference is resolved within the same share namespace.
// Composite traffic limiters use a comma-separated list in GOST.
func scopePeerResourceReferences(value interface{}, shareID int64) {
switch v := value.(type) {
case map[string]interface{}:
for key, child := range v {
if strings.EqualFold(key, "chains") {
if names, ok := child.([]interface{}); ok {
for i, name := range names {
if raw, ok := name.(string); ok {
names[i] = peerShareResourceName(shareID, "chain", raw)
}
}
continue
}
}
kind := ""
switch strings.ToLower(key) {
case "chain":
kind = "chain"
case "limiter":
kind = "limiter"
case "climiter":
kind = "climiter"
}
if raw, ok := child.(string); ok && kind != "" && strings.TrimSpace(raw) != "" {
names := strings.Split(raw, ",")
for i, name := range names {
names[i] = peerShareResourceName(shareID, kind, strings.TrimSpace(name))
}
v[key] = strings.Join(names, ",")
} else {
scopePeerResourceReferences(child, shareID)
}
}
case []interface{}:
for _, child := range v {
scopePeerResourceReferences(child, shareID)
}
}
}
func peerResourceDeletePayload(item repo.PeerShareResource) interface{} {
if item.Kind == "service" {
return map[string]interface{}{"services": []string{item.RuntimeName}}
}
key := item.Kind
if key == "climiter" {
key = "limiter"
}
return map[string]interface{}{key: item.RuntimeName}
}
func peerResourceDeleteCommand(kind string) string {
switch kind {
case "service":
return "DeleteService"
case "chain":
return "DeleteChains"
case "climiter":
return "DeleteCLimiters"
default:
return "DeleteLimiters"
}
}
// preparePeerResourceCommand validates and writes desired state before the node
// sees any command, closing the flow-report race even without a reservation.
func (h *Handler) preparePeerResourceCommand(share *repo.PeerShare, cmd string, data interface{}) ([]repo.PeerShareResource, error) {
kind, action := federationResourceCommandKind(cmd)
if kind == "" {
return nil, fmt.Errorf("command not allowed")
}
raw, err := json.Marshal(data)
if err != nil {
return nil, err
}
var decoded interface{}
if err = json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
var configs []map[string]interface{}
var names []string
setting := action == "add" || action == "update"
if setting {
if kind == "service" {
configs = extractFederationServiceEntries(decoded)
} else if m, ok := decoded.(map[string]interface{}); ok {
if nested, ok := m["data"].(map[string]interface{}); ok {
configs = []map[string]interface{}{nested}
} else {
configs = []map[string]interface{}{m}
}
}
if len(configs) == 0 {
return nil, fmt.Errorf("resource configuration is required")
}
for _, c := range configs {
names = append(names, strings.TrimSpace(asString(c["name"])))
}
} else {
if kind == "service" {
if m, ok := decoded.(map[string]interface{}); ok {
for _, v := range asAnySlice(m["services"]) {
names = append(names, strings.TrimSpace(asString(v)))
}
}
} else {
if s, ok := decoded.(string); ok {
names = []string{strings.TrimSpace(s)}
} else if m, ok := decoded.(map[string]interface{}); ok {
key := kind
if key == "climiter" {
key = "limiter"
}
names = []string{strings.TrimSpace(asString(m[key]))}
}
}
if len(names) == 0 {
return nil, fmt.Errorf("resource name is required")
}
}
requestedNames := make(map[string]bool, len(names))
for _, name := range names {
requestedNames[name] = true
}
items := make([]repo.PeerShareResource, 0, len(names))
seen := map[string]bool{}
for i, name := range names {
if name == "" || strings.HasPrefix(name, peerShareResourcePrefix) {
return nil, fmt.Errorf("invalid peer resource name")
}
if seen[name] {
return nil, fmt.Errorf("duplicate resource name")
}
seen[name] = true
old, err := h.repo.GetPeerShareResource(share.ID, kind, name)
if err != nil {
return nil, err
}
item := repo.PeerShareResource{ShareID: share.ID, NodeID: share.NodeID, Kind: kind, OriginalName: name, RuntimeName: peerShareResourceName(share.ID, kind, name), DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}
if old != nil {
item = *old
item.Applied = 0
item.UpdatedTime = time.Now().UnixMilli()
}
if setting {
if old == nil && kind == "service" {
legacy, err := h.peerShareLegacyServiceNames(share, name)
if err != nil {
return nil, err
}
if len(legacy) > 0 {
encoded, _ := json.Marshal(legacy)
item.LegacyNames = string(encoded)
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
}
}
config := configs[i]
if err := validatePeerResourceReferences(config, kind); err != nil {
return nil, err
}
scopePeerResourceReferences(config, share.ID)
config["name"] = item.RuntimeName
encoded, err := json.Marshal(config)
if err != nil {
return nil, err
}
item.Config = string(encoded)
item.DesiredState = "active"
item.ReleaseLegacyFamily = false
} else {
if old == nil {
// Delete callers send candidate base/TCP/UDP names. Missing names
// are safe no-ops; never forward a raw legacy name to the node.
if action == "delete" {
if kind != "service" {
continue
}
legacy, err := h.peerShareLegacyServiceNames(share, name)
if err != nil {
return nil, err
}
if len(legacy) == 0 {
continue
}
encoded, _ := json.Marshal(legacy)
item.LegacyNames = string(encoded)
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
} else {
return nil, fmt.Errorf("service %q not found", name)
}
}
if old != nil && old.DesiredState == "deleted" && action != "delete" {
return nil, fmt.Errorf("service %q not found", name)
}
switch action {
case "delete":
item.DesiredState = "deleted"
base := normalizeForwardRuntimeServiceName(name)
if requestedNames[base] && requestedNames[base+"_tcp"] && requestedNames[base+"_udp"] {
item.ReleaseLegacyFamily = true
}
case "pause":
item.DesiredState = "paused"
case "resume":
if item.Config == "" {
return nil, fmt.Errorf("resource has no saved configuration")
}
item.DesiredState = "active"
default:
return nil, fmt.Errorf("command not allowed")
}
}
items = append(items, item)
}
// Ownership and desired config commit atomically. A failed resource write
// must not rename a legacy binding and expose its remaining transports.
err = h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
if setting && kind == "service" {
scoped := make([]interface{}, 0, len(items))
for _, item := range items {
var c map[string]interface{}
_ = json.Unmarshal([]byte(item.Config), &c)
scoped = append(scoped, c)
}
binder := &Handler{repo: tx}
if err := binder.bindPeerShareForwardRuntimeServices(share, scoped); err != nil {
return err
}
}
return tx.SavePeerShareResources(items)
})
if err != nil {
return nil, err
}
return items, nil
}
func (h *Handler) applyPeerShareResource(item repo.PeerShareResource) (ws.CommandResult, error) {
var result ws.CommandResult
var err error
if item.LegacyNames != "" {
var names []string
if err := json.Unmarshal([]byte(item.LegacyNames), &names); err != nil {
return result, err
}
share, loadErr := h.repo.GetPeerShare(item.ShareID)
if loadErr != nil {
return result, loadErr
}
if share == nil {
return result, fmt.Errorf("legacy resource ownership missing")
}
for _, name := range names {
owned, checkErr := h.peerShareLegacyServiceNames(share, name)
if checkErr != nil {
return result, checkErr
}
if len(owned) == 0 {
return result, fmt.Errorf("legacy resource ownership missing")
}
}
if _, err = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
return result, err
}
if err = h.repo.ClearPeerShareResourceLegacyNames(item.ShareID, item.Kind, item.OriginalName); err != nil {
return result, err
}
}
if item.DesiredState == "deleted" || item.DesiredState == "paused" {
// The provider retains the paused configuration durably. Keep the
// listener absent on reconnect instead of briefly starting it before
// sending a second pause command. Resume reapplies the saved config.
result, err = h.sendNodeCommand(item.NodeID, peerResourceDeleteCommand(item.Kind), peerResourceDeletePayload(item), false, true)
} else {
var config map[string]interface{}
if err = json.Unmarshal([]byte(item.Config), &config); err != nil {
return result, err
}
if item.Kind == "service" {
result, err = h.sendNodeCommand(item.NodeID, "UpdateService", []interface{}{config}, false, false)
} else {
suffix := "Limiters"
key := "limiter"
if item.Kind == "chain" {
suffix = "Chains"
key = "chain"
}
if item.Kind == "climiter" {
suffix = "CLimiters"
}
result, err = h.sendNodeCommand(item.NodeID, "Add"+suffix, config, false, false)
if err != nil && isAlreadyExistsMessage(err.Error()) {
result, err = h.sendNodeCommand(item.NodeID, "Update"+suffix, map[string]interface{}{key: item.RuntimeName, "data": config}, false, false)
}
}
}
if err != nil {
return result, err
}
if item.Kind == "service" && item.DesiredState == "deleted" {
// TCP and UDP may share one reservation. Release only after all variants
// have an acknowledged tombstone.
items, listErr := h.repo.ListPeerShareResourcesByNode(item.NodeID)
if listErr != nil {
return result, listErr
}
base := normalizeForwardRuntimeServiceName(item.OriginalName)
for _, other := range items {
if other.ShareID == item.ShareID && other.Kind == "service" && other.OriginalName != item.OriginalName && normalizeForwardRuntimeServiceName(other.OriginalName) == base && (other.DesiredState != "deleted" || other.Applied == 0) {
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
}
legacyBase := item.LegacyServiceBase
if legacyBase == "" {
for _, other := range items {
if other.ShareID == item.ShareID && normalizeForwardRuntimeServiceName(other.OriginalName) == base && other.LegacyServiceBase != "" {
legacyBase = other.LegacyServiceBase
break
}
}
}
if legacyBase != "" && item.ReleaseLegacyFamily {
share, loadErr := h.repo.GetPeerShare(item.ShareID)
if loadErr != nil {
return result, loadErr
}
if share == nil {
return result, fmt.Errorf("share ownership missing")
}
if _, checkErr := h.peerShareLegacyServiceNames(share, legacyBase); checkErr != nil {
return result, checkErr
}
if _, deleteErr := h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": buildForwardServiceDeleteNames([]string{legacyBase})}, false, true); deleteErr != nil {
return result, deleteErr
}
}
if legacyBase != "" && !item.ReleaseLegacyFamily {
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
runtimes, listErr := h.repo.ListActivePeerShareRuntimesByShareID(item.ShareID)
if listErr != nil {
return result, listErr
}
return result, h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
for _, runtime := range runtimes {
sid, original, scoped := parsePeerShareServiceName(runtime.ServiceName)
ownedScoped := scoped && sid == item.ShareID && normalizeForwardRuntimeServiceName(original) == base
ownedLegacy := !scoped && runtime.Role == "forward" && item.ReleaseLegacyFamily && legacyBase != "" && normalizeForwardRuntimeServiceName(runtime.ServiceName) == legacyBase
if ownedScoped || ownedLegacy {
if err = tx.CompletePeerShareRuntimeRelease(runtime.ID); err != nil {
return err
}
}
}
if legacyBase != "" && item.ReleaseLegacyFamily {
if err := tx.ClearPeerShareResourceLegacyFamily(item.ShareID, legacyBase); err != nil {
return err
}
}
return tx.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
})
}
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
func orderPeerShareResources(items []repo.PeerShareResource) {
rank := func(item repo.PeerShareResource) int {
if item.DesiredState == "deleted" {
if item.Kind == "service" {
return 0
}
return 1
}
if item.Kind == "service" {
return 4
}
if item.Kind == "chain" {
return 3
}
return 2
}
sort.SliceStable(items, func(i, j int) bool { return rank(items[i]) < rank(items[j]) })
}
func (h *Handler) reconcilePeerShareResourcesOnNode(nodeID int64) error {
return h.reconcilePeerShareResources(nodeID, false)
}
func (h *Handler) retryPendingPeerShareResourcesOnNode(nodeID int64) error {
return h.reconcilePeerShareResources(nodeID, true)
}
func (h *Handler) reconcilePeerShareResources(nodeID int64, pendingOnly bool) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
items, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
return err
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
if err != nil {
return err
}
pending := map[string]bool{}
for _, runtime := range runtimes {
if runtime.ReleasePending != 0 {
sid, name, ok := parsePeerShareServiceName(runtime.ServiceName)
if ok {
pending[fmt.Sprintf("%d:%s", sid, normalizeForwardRuntimeServiceName(name))] = true
}
}
}
groups := map[int64][]repo.PeerShareResource{}
var ids []int64
for _, item := range items {
if _, ok := groups[item.ShareID]; !ok {
ids = append(ids, item.ShareID)
}
groups[item.ShareID] = append(groups[item.ShareID], item)
}
var failures []error
for _, shareID := range ids {
share, err := h.repo.GetPeerShare(shareID)
if err != nil {
failures = append(failures, err)
continue
}
expired := share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)
group := groups[shareID]
var changed []repo.PeerShareResource
for i := range group {
item := &group[i]
if (expired || (item.Kind == "service" && pending[fmt.Sprintf("%d:%s", item.ShareID, normalizeForwardRuntimeServiceName(item.OriginalName))])) && (item.DesiredState != "deleted" || item.Applied == 0 || item.LegacyServiceBase != "") {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
changed = append(changed, *item)
}
}
if err := h.repo.SavePeerShareResources(changed); err != nil {
failures = append(failures, err)
continue
}
orderPeerShareResources(group)
dependencyFailed := false
deletionFailed := false
for _, item := range group {
if item.Applied == 1 && (pendingOnly || item.DesiredState == "deleted") {
continue
}
if dependencyFailed && item.Kind == "service" && item.DesiredState != "deleted" {
continue
}
if deletionFailed && item.Kind != "service" && item.DesiredState == "deleted" {
continue
}
if _, err := h.applyPeerShareResource(item); err != nil {
failures = append(failures, fmt.Errorf("share %d %s %s: %w", item.ShareID, item.Kind, item.OriginalName, err))
if item.Kind != "service" {
dependencyFailed = true
}
if item.DesiredState == "deleted" {
deletionFailed = true
}
}
}
}
return errors.Join(failures...)
}
func (h *Handler) releasePeerShareResources(shareID int64) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
share, err := h.repo.GetPeerShare(shareID)
if err != nil {
return err
}
if share == nil {
return nil
}
all, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
if err != nil {
return err
}
var items []repo.PeerShareResource
for _, item := range all {
if item.ShareID == shareID && !(item.DesiredState == "deleted" && item.Applied == 1 && item.LegacyServiceBase == "") {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
items = append(items, item)
}
}
if err = h.repo.SavePeerShareResources(items); err != nil {
return err
}
orderPeerShareResources(items)
for _, item := range items {
if _, err = h.applyPeerShareResource(item); err != nil {
return err
}
}
return nil
}
// A legacy name is eligible for migration only when its runtime registration
// proves unique ownership. An identically numbered local forward is ambiguous.
func (h *Handler) peerShareLegacyServiceNames(share *repo.PeerShare, original string) ([]string, error) {
base := normalizeForwardRuntimeServiceName(original)
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(share.NodeID, base)
if err != nil {
return nil, err
}
owned := false
for _, runtime := range runtimes {
if runtime.ShareID != share.ID {
return nil, fmt.Errorf("legacy resource %q has ambiguous ownership", original)
}
if runtime.ReleasePending != 0 {
return nil, fmt.Errorf("runtime release is pending")
}
owned = true
}
if !owned {
resources, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
if err != nil {
return nil, err
}
for _, resource := range resources {
if resource.ShareID == share.ID && resource.LegacyServiceBase == base {
owned = true
break
}
}
}
if !owned {
return nil, nil
}
if id, _, _, ok := parseFlowServiceIDs(base); ok {
local, err := h.repo.GetForwardRecord(id)
if err != nil {
return nil, err
}
if local != nil {
return nil, fmt.Errorf("legacy resource %q collides with a local forward", original)
}
}
// Only delete the requested transport. Other variants remain registered to
// their legacy runtime until they are independently migrated.
return []string{original}, nil
}
// releasePeerShareForwardRuntimeResources removes exact persisted transport
// names belonging to a single forward reservation, retaining failed tombstones.
func (h *Handler) releasePeerShareForwardRuntimeResources(runtime *repo.PeerShareRuntime) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
shareID, original, ok := parsePeerShareServiceName(runtime.ServiceName)
if !ok || shareID != runtime.ShareID {
return fmt.Errorf("runtime is not a scoped shared forward")
}
all, err := h.repo.ListPeerShareResourcesByNode(runtime.NodeID)
if err != nil {
return err
}
var items []repo.PeerShareResource
for _, item := range all {
if item.ShareID == runtime.ShareID && item.Kind == "service" && normalizeForwardRuntimeServiceName(item.OriginalName) == normalizeForwardRuntimeServiceName(original) {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
items = append(items, item)
}
}
if len(items) == 0 {
return fmt.Errorf("shared forward resource ownership is missing")
}
if err = h.repo.SavePeerShareResources(items); err != nil {
return err
}
for _, item := range items {
if _, err = h.applyPeerShareResource(item); err != nil {
return err
}
}
return nil
}
// Registry kinds without an owned command API cannot be referenced by peers.
func validatePeerResourceReferences(value interface{}, kind string) error {
var walk func(interface{}) error
walk = func(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
for key, child := range v {
switch strings.ToLower(key) {
case "auther", "authers", "admission", "admissions", "bypass", "bypasses", "resolver", "hosts", "rlimiter", "logger", "loggers", "observer", "recorders", "hop", "sd":
if child != nil {
empty := false
switch x := child.(type) {
case string:
empty = strings.TrimSpace(x) == ""
case []interface{}:
empty = len(x) == 0
}
if !empty {
return fmt.Errorf("unsupported shared registry reference: %s", key)
}
}
case "forwarder":
if f, ok := child.(map[string]interface{}); ok {
for field, value := range f {
if strings.EqualFold(field, "name") && strings.TrimSpace(asString(value)) != "" {
return fmt.Errorf("named shared forwarder references are unsupported")
}
}
}
case "hops":
for _, hop := range asMapSlice(child) {
if _, ok := hop["nodes"]; !ok {
return fmt.Errorf("shared chains require inline hop nodes")
}
for _, loader := range []string{"file", "redis", "http", "plugin"} {
if hop[loader] != nil {
return fmt.Errorf("shared hop loaders are unsupported")
}
}
}
}
if err := walk(child); err != nil {
return err
}
}
case []interface{}:
for _, child := range v {
if err := walk(child); err != nil {
return err
}
}
}
return nil
}
if kind == "limiter" || kind == "climiter" {
if config, ok := value.(map[string]interface{}); ok {
for _, loader := range []string{"file", "redis", "http", "plugin"} {
if config[loader] != nil {
return fmt.Errorf("shared limiter loaders are unsupported")
}
}
}
}
return walk(value)
}
@@ -1,538 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
"gorm.io/gorm"
)
func resourceTestShare(t *testing.T, h *Handler, token string) *repo.PeerShare {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: token, NodeID: 1, Token: token, IsActive: 1, PortRangeStart: 31000, PortRangeEnd: 32000, CreatedTime: now, UpdatedTime: now}
if err := h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
return share
}
func resourceTestCommand(t *testing.T, h *Handler, share *repo.PeerShare, cmd string, data interface{}) response.R {
t.Helper()
body, err := json.Marshal(federationRuntimeCommandRequest{CommandType: cmd, Data: data})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
rec := httptest.NewRecorder()
h.federationRuntimeCommand(rec, req)
var result response.R
if err = json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func resourceTestService(name string, port int) []interface{} {
return []interface{}{map[string]interface{}{"name": name, "addr": fmt.Sprintf(":%d", port), "handler": map[string]interface{}{"type": "tcp", "chain": "70"}, "listener": map[string]interface{}{"type": "tcp"}, "limiter": "10,20", "climiter": "30"}}
}
func TestPeerResourceCommandIsolationAndDurableRestore(t *testing.T) {
a := newCleanupAgent(t)
first := resourceTestShare(t, a.h, "resource-first")
second := resourceTestShare(t, a.h, "resource-second")
for i, share := range []*repo.PeerShare{first, second} {
for _, entry := range []struct{ cmd, name string }{{"AddLimiters", "10"}, {"AddCLimiters", "30"}, {"AddChains", "70"}} {
result := resourceTestCommand(t, a.h, share, entry.cmd, map[string]interface{}{"name": entry.name})
if result.Code != 0 {
t.Fatal(result.Msg)
}
}
result := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001+i))
if result.Code != 0 {
t.Fatal(result.Msg)
}
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 2 {
t.Fatalf("commands: %+v", commands)
}
for i, share := range []*repo.PeerShare{first, second} {
var configs []map[string]interface{}
if err := json.Unmarshal(commands[i].Data, &configs); err != nil {
t.Fatal(err)
}
c := configs[0]
if c["name"] != peerShareResourceName(share.ID, "service", "70_1_0_tcp") {
t.Fatalf("unscoped service: %v", c)
}
if c["handler"].(map[string]interface{})["chain"] != peerShareResourceName(share.ID, "chain", "70") {
t.Fatalf("unscoped chain: %v", c)
}
want := peerShareResourceName(share.ID, "limiter", "10") + "," + peerShareResourceName(share.ID, "limiter", "20")
if c["limiter"] != want || c["climiter"] != peerShareResourceName(share.ID, "climiter", "30") {
t.Fatalf("unscoped limiter: %v", c)
}
}
result := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}})
if result.Code != 0 {
t.Fatal(result.Msg)
}
deleted := a.commandsOfType("DeleteService")
if len(deleted) != 1 || !strings.Contains(string(deleted[0].Data), peerShareResourceName(first.ID, "service", "70_1_0_tcp")) {
t.Fatalf("wrong deletion: %+v", deleted)
}
// A new Handler simulates restart with only durable desired state retained.
restarted := &Handler{repo: a.h.repo, wsServer: a.h.wsServer}
if err := restarted.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
commands = a.commandsOfType("UpdateService")
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0_tcp")) {
t.Fatalf("wrong recovery: %+v", commands)
}
if got := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{peerShareResourceName(second.ID, "service", "70_1_0_tcp")}}); got.Code == 0 {
t.Fatal("accepted foreign scoped name")
}
if got := resourceTestCommand(t, a.h, first, "Reload", nil); got.Code == 0 {
t.Fatal("accepted global reload")
}
}
func TestPeerResourcePreRegistrationAndDatabaseFailure(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-preregister")
items, err := a.h.preparePeerResourceCommand(share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("prepare sent service before persistence")
}
stored, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || stored == nil || stored.Applied != 0 {
t.Fatalf("missing pending ownership: %+v %v", stored, err)
}
runCleanupPath(a.h, "single", []string{items[0].RuntimeName})
a.probe(t)
if len(a.commandsOfType("DeleteService")) != 0 {
t.Fatal("flow report removed pending service")
}
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-resource-registration", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_resource" {
tx.AddError(errors.New("simulated resource write failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-resource-registration")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002))
if got.Code == 0 {
t.Fatal("database failure returned success")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("node received command after persistence failure")
}
}
func TestPeerResourceBindingDatabaseFailure(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-bind-failure")
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-runtime-registration", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated binding failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-runtime-registration")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001))
if got.Code == 0 {
t.Fatal("binding failure returned success")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("service sent before successful binding")
}
}
func TestPeerResourceScopedNameRoundTrip(t *testing.T) {
for _, name := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp", "a-b_c"} {
scoped := peerShareResourceName(12, "service", name)
id, got, ok := parsePeerShareServiceName(scoped)
if !ok || id != 12 || got != name {
t.Fatalf("round trip failed: %q", scoped)
}
}
}
func TestPeerResourceDeletesCandidateNamesAndPreservesFailedTombstone(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-delete-candidates")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if got.Code != 0 {
t.Fatal(got.Msg)
}
got = resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
if got.Code != 0 {
t.Fatal(got.Msg)
}
if len(a.commandsOfType("DeleteService")) != 1 {
t.Fatal("unregistered names were sent to the node")
}
// A disconnected node cannot acknowledge deletion: keep the pending row.
if got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
t.Fatal(got.Msg)
}
offline := &Handler{repo: a.h.repo}
got = resourceTestCommand(t, offline, share, "DeleteService", map[string]interface{}{"services": []string{"71_1_0"}})
if got.Code == 0 {
t.Fatal("offline deletion reported success")
}
pending, err := a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
if err != nil || pending.DesiredState != "deleted" || pending.Applied != 0 {
t.Fatalf("lost pending deletion: %+v %v", pending, err)
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
pending, err = a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
if err != nil || pending.Applied != 1 {
t.Fatalf("deletion not retried: %+v %v", pending, err)
}
if got = resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"71_1_0"}}); got.Code == 0 {
t.Fatal("resume resurrected a deleted resource")
}
}
func TestPeerResourceLegacyMigrationRequiresUnambiguousOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if got.Code != 0 {
t.Fatal(got.Msg)
}
deleted := a.commandsOfType("DeleteService")
if len(deleted) != 1 || string(deleted[0].Data) != `{"services":["70_1_0_tcp"]}` {
t.Fatalf("legacy deletion was not exact: %+v", deleted)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyNames != "" {
t.Fatalf("legacy migration acknowledgment not persisted: %+v %v", item, err)
}
// Two shares with the same legacy name are never resolved by guessing.
other := resourceTestShare(t, a.h, "resource-ambiguous")
now := time.Now().UnixMilli()
for i, sid := range []int64{share.ID, other.ID} {
if err := a.h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{ShareID: sid, NodeID: 1, ReservationID: fmt.Sprintf("ambiguous-%d", i), ResourceKey: fmt.Sprintf("ambiguous-%d", i), Role: "forward", ServiceName: "71_1_0", Port: 31003 + i, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}); err != nil {
t.Fatal(err)
}
}
got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0_tcp", 31003))
if got.Code == 0 {
t.Fatal("ambiguous legacy owner accepted")
}
if len(a.commandsOfType("DeleteService")) != 1 {
t.Fatal("ambiguous legacy service deleted")
}
}
func TestPeerResourceLegacyFamilyPersistsAcrossPartialMigration(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "70_1_0" {
t.Fatalf("lost legacy family ownership: %+v %v", item, err)
}
// A later request can still prove ownership of the old UDP transport.
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_udp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
deletes := a.commandsOfType("DeleteService")
if len(deletes) != 2 || string(deletes[1].Data) != `{"services":["70_1_0_udp"]}` {
t.Fatalf("lost UDP migration ownership: %+v", deletes)
}
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "" {
t.Fatalf("legacy family not released: %+v %v", item, err)
}
}
func TestPeerResourceFailedRegistrationDoesNotRenameLegacyRuntime(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-atomic-resource", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_resource" {
tx.AddError(errors.New("simulated desired-state failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-atomic-resource")
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code == 0 {
t.Fatal("registration failure returned success")
}
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
if err != nil || len(runtimes) != 1 || runtimes[0].ServiceName != "70_1_0" {
t.Fatalf("legacy binding changed after rollback: %+v %v", runtimes, err)
}
if len(a.commandsOfType("DeleteService"))+len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("node mutated despite transaction rollback")
}
}
func TestPeerResourcePausedReconcileNeverStartsListener(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-pause-recovery")
for _, cmd := range []string{"AddService", "PauseService"} {
var data interface{} = resourceTestService("70_1_0_tcp", 31001)
if cmd == "PauseService" {
data = map[string]interface{}{"services": []string{"70_1_0_tcp"}}
}
if got := resourceTestCommand(t, a.h, share, cmd, data); got.Code != 0 {
t.Fatal(got.Msg)
}
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 1 {
t.Fatal("paused listener started during reconciliation")
}
if got := resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
if len(a.commandsOfType("UpdateService")) != 2 {
t.Fatal("resume did not restore saved service")
}
}
func TestPeerResourceDeleteUnmigratedLegacyOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
if got.Code != 0 {
t.Fatal(got.Msg)
}
removed := false
for _, cmd := range a.commandsOfType("DeleteService") {
var body struct {
Services []string `json:"services"`
}
if err = json.Unmarshal(cmd.Data, &body); err != nil {
t.Fatal(err)
}
for _, name := range body.Services {
if name == "70_1_0" {
removed = true
}
}
}
if !removed {
t.Fatal("legacy delete reported success without sending old service deletion")
}
}
func TestPeerResourcePartialDeletePreservesLegacyFamily(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "70_1_0" {
t.Fatalf("partial delete lost remaining legacy family: %+v %v", item, err)
}
for _, cmd := range a.commandsOfType("DeleteService") {
if strings.Contains(string(cmd.Data), `"70_1_0_udp"`) {
t.Fatal("partial TCP delete removed legacy UDP")
}
}
if err = a.h.releasePeerShareResources(share.ID); err != nil {
t.Fatal(err)
}
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "" {
t.Fatalf("full release lost legacy cleanup: %+v %v", item, err)
}
}
func TestPeerResourceChainGroupsAreScopedAndOtherRegistriesRejected(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-chain-groups")
data := resourceTestService("70_1_0", 31001)
data[0].(map[string]interface{})["handler"].(map[string]interface{})["chainGroup"] = map[string]interface{}{"chains": []string{"one", "two"}}
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code != 0 {
t.Fatal(got.Msg)
}
body := string(a.commandsOfType("UpdateService")[0].Data)
for _, name := range []string{"one", "two"} {
if !strings.Contains(body, peerShareResourceName(share.ID, "chain", name)) {
t.Fatalf("chainGroup reference was not scoped: %s", body)
}
}
for _, reference := range []string{"resolver", "auther", "observer", "hop"} {
data := resourceTestService("71_1_0", 31002)
data[0].(map[string]interface{})[reference] = "global-resource"
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code == 0 {
t.Fatalf("accepted global %s reference", reference)
}
}
}
func TestPeerResourceReconcileContinuesAfterAnotherShareFails(t *testing.T) {
a := newCleanupAgent(t)
first := resourceTestShare(t, a.h, "resource-failed-share")
second := resourceTestShare(t, a.h, "resource-good-share")
for i, share := range []*repo.PeerShare{first, second} {
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001+i)); got.Code != 0 {
t.Fatal(got.Msg)
}
}
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: first.ID, NodeID: 1, Kind: "limiter", OriginalName: "broken", RuntimeName: peerShareResourceName(first.ID, "limiter", "broken"), Config: "{", DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}}); err != nil {
t.Fatal(err)
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err == nil {
t.Fatal("invalid dependency was not reported")
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0")) {
t.Fatalf("failed share blocked healthy share, or failed dependency service was started: %+v", commands)
}
}
func TestPeerResourceRechecksShareBeforeRecreation(t *testing.T) {
for _, state := range []string{"inactive", "expired", "exceeded"} {
t.Run(state, func(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-state-"+state)
switch state {
case "inactive":
share.IsActive = 0
case "expired":
share.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli()
case "exceeded":
share.MaxBandwidth = 1
share.CurrentFlow = 1 << 40
}
if err := a.h.repo.UpdatePeerShare(share); err != nil {
t.Fatal(err)
}
if state == "exceeded" {
if err := a.h.repo.AddPeerShareCurrentFlow(share.ID, share.CurrentFlow); err != nil {
t.Fatal(err)
}
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
t.Fatal("invalid share recreated resources")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("invalid share reached the node")
}
})
}
}
func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-pending-only")
offline := &Handler{repo: a.h.repo}
if got := resourceTestCommand(t, offline, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
t.Fatal("offline apply reported success")
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
t.Fatal(got.Msg)
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 2 || !strings.Contains(string(commands[1].Data), peerShareResourceName(share.ID, "service", "70_1_0")) {
t.Fatalf("pending retry restarted applied sibling: %+v", commands)
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 2 {
t.Fatal("no-op pending retry restarted applied services")
}
}
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated completion failure"))
}
}); err != nil {
t.Fatal(err)
}
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}})
if got.Code == 0 {
t.Fatal("completion database failure reported success")
}
items, err := a.h.repo.ListPeerShareResourcesByNode(1)
if err != nil {
t.Fatal(err)
}
proof := false
pending := false
for _, item := range items {
proof = proof || item.LegacyServiceBase == "70_1_0"
pending = pending || item.Applied == 0
}
if !proof || !pending {
t.Fatalf("failure lost ownership or retry marker: %+v", items)
}
if err := a.h.repo.DB().Callback().Update().Remove("fail-runtime-release"); err != nil {
t.Fatal(err)
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
if err != nil || len(runtimes) != 0 {
t.Fatalf("retry leaked legacy reservation: %+v %v", runtimes, err)
}
}
@@ -224,14 +224,18 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
}
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
agent := newCleanupAgent(t)
r := agent.h.repo
h := agent.h
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-cleanup-share",
NodeID: 1,
NodeID: 99,
Token: "delete-cleanup-token",
MaxBandwidth: 4096,
PortRangeStart: 40000,
@@ -253,8 +257,8 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
share.ID, 1, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 1, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
@@ -1,300 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type cleanupAgentCommand struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
RequestID string `json:"requestId"`
}
// cleanupAgent exercises the actual command transport. Each command is recorded
// before its ACK, so synchronous handler calls need no sleeps to inspect it.
type cleanupAgent struct {
h *Handler
mu sync.Mutex
commands []cleanupAgentCommand
}
func newCleanupAgent(t *testing.T) *cleanupAgent {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
const secret = "cleanup-regression-node"
if err := r.DB().Create(&model.Node{ID: 1, Name: "relay", Secret: secret, ServerIP: "127.0.0.1", Port: "30000", Status: 1}).Error; err != nil {
t.Fatal(err)
}
server := ws.NewServer(r, "test-jwt-secret")
online := make(chan struct{})
server.SetNodeOnlineHook(func(int64) { close(online) })
serverDone := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer close(serverDone)
server.ServeHTTP(w, req)
}))
t.Cleanup(ts.Close)
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http")+"?type=1&secret="+secret, nil)
if err != nil {
t.Fatal(err)
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
t.Fatal(err)
}
a := &cleanupAgent{h: &Handler{repo: r, wsServer: server}}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
var envelope struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
t.Errorf("decode command envelope: %v", err)
return
}
if envelope.Encrypted {
payload, err = crypto.Decrypt(envelope.Data)
if err != nil {
t.Errorf("decrypt command: %v", err)
return
}
}
var command cleanupAgentCommand
if err := json.Unmarshal(payload, &command); err != nil {
t.Errorf("decode command: %v", err)
return
}
a.mu.Lock()
a.commands = append(a.commands, command)
a.mu.Unlock()
if err := conn.WriteJSON(map[string]interface{}{"type": command.Type, "requestId": command.RequestID, "success": true}); err != nil {
return
}
}
}()
t.Cleanup(func() {
_ = conn.Close()
<-readerDone
<-serverDone
})
select {
case <-online:
case <-time.After(3 * time.Second):
t.Fatal("mock node did not come online")
}
a.probe(t)
return a
}
func (a *cleanupAgent) probe(t *testing.T) {
t.Helper()
if _, err := a.h.wsServer.SendCommand(1, "CleanupTestProbe", nil, time.Second); err != nil {
t.Fatalf("mock agent transport unavailable: %v", err)
}
}
func (a *cleanupAgent) commandsOfType(commandType string) []cleanupAgentCommand {
a.mu.Lock()
defer a.mu.Unlock()
var commands []cleanupAgentCommand
for _, command := range a.commands {
if command.Type == commandType {
commands = append(commands, command)
}
}
return commands
}
func (a *cleanupAgent) addRuntime(t *testing.T, name string, nodeID int64, status, applied int, updated time.Time) int64 {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: "shared", NodeID: nodeID, Token: "cleanup-share-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
if err := a.h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
stored, err := a.h.repo.GetPeerShareByToken(share.Token)
if err != nil || stored == nil {
t.Fatalf("load share: %v", err)
}
// SQL preserves status=0; GORM's default tag would replace that with 1.
if err := a.h.repo.DB().Exec(`INSERT INTO peer_share_runtime
(share_id, node_id, reservation_id, resource_key, role, service_name, applied, status, created_time, updated_time)
VALUES (?, ?, 'cleanup-reservation', 'cleanup-resource', 'forward', ?, ?, ?, ?, ?)`,
stored.ID, nodeID, name, applied, status, now, updated.UnixMilli()).Error; err != nil {
t.Fatal(err)
}
return stored.ID
}
func TestSharedForwardCleanupRegression(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
for _, runtimeName := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"} {
t.Run(mode+"/active/"+runtimeName, func(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, runtimeName, 1, 1, 1, time.Now())
runCleanupPath(a.h, mode, []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("active shared service family was deleted: %+v", commands)
}
if mode != "config" && runtimeName == "70_1_0" {
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil || share == nil || share.CurrentFlow != 600 {
t.Fatalf("shared traffic should accumulate 600 bytes: share=%+v err=%v", share, err)
}
}
})
}
for _, tc := range []struct {
name string
serviceName string
nodeID int64
status int
applied int
age time.Duration
wantDelete bool
}{
{name: "recent-unbound", nodeID: 1, status: 1, age: time.Minute},
{name: "stale-unbound", nodeID: 1, status: 1, age: 11 * time.Minute, wantDelete: true},
{name: "released", serviceName: "70_1_0", nodeID: 1, applied: 1, wantDelete: true},
{name: "other-node-bound", serviceName: "70_1_0", nodeID: 2, status: 1, applied: 1, wantDelete: true},
{name: "other-node-unbound", nodeID: 2, status: 1, wantDelete: true},
{name: "unrelated-active-family", serviceName: "71_1_0", nodeID: 1, status: 1, applied: 1, wantDelete: true},
} {
t.Run(mode+"/"+tc.name, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, tc.serviceName, tc.nodeID, tc.status, tc.applied, time.Now().Add(-tc.age))
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
commands := a.commandsOfType("DeleteService")
if !tc.wantDelete {
if len(commands) != 0 {
t.Fatalf("recent unbound shared runtime was deleted: %+v", commands)
}
return
}
if len(commands) != 1 {
t.Fatalf("expected one orphan cleanup command, got %+v", commands)
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
t.Fatal(err)
}
sort.Strings(data.Services)
if want := []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}; !reflect.DeepEqual(data.Services, want) {
t.Fatalf("orphan cleanup must delete complete family: got %v, want %v", data.Services, want)
}
})
}
}
}
func runCleanupPath(h *Handler, mode string, names []string) {
items := make([]flowItem, 0, len(names))
services := make([]namedConfigItem, 0, len(names))
for _, name := range names {
items = append(items, flowItem{N: name, U: 120, D: 80})
services = append(services, namedConfigItem{Name: name})
}
switch mode {
case "single":
for _, item := range items {
h.processFlowItem(1, item)
}
case "batch":
h.applyFlowUploadBatch(1, h.buildNodeFlowUploadBatch(1, items, nil), time.Now())
case "config":
h.cleanOrphanedServices(1, services)
}
}
func TestSharedForwardCleanupMissingMetadataPreservesLocalForward(t *testing.T) {
a := newCleanupAgent(t)
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
runCleanupPath(a.h, "batch", []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("missing batch metadata must not delete a local forward: %+v", commands)
}
}
func TestSharedForwardCleanupChecksActualDeleteFamily(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
// Legacy parsing accepts additional suffixes. Cleanup must check
// the family it would delete, not just the reported service name.
runCleanupPath(a.h, mode, []string{"70_1_0_old", "70_1_0_old_tcp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("suffix variation must not delete a protected shared family: %+v", commands)
}
})
}
}
func TestSharedForwardCleanupQueryFailurePreservesServices(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
for _, failure := range []string{"forward", "peer_share_runtime"} {
t.Run(mode+"/"+failure, func(t *testing.T) {
a := newCleanupAgent(t)
// Fail only the ownership lookup; leave node lookup and WebSocket
// delivery functional so an erroneous delete remains observable.
callback := "test:cleanup-query-failure"
var injected atomic.Int32
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == failure {
injected.Add(1)
tx.AddError(errors.New("injected ownership query failure"))
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
if injected.Load() == 0 {
t.Fatal("test did not inject the expected query failure")
}
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("ownership query failure must preserve services: %+v", commands)
}
})
}
}
}
@@ -1,145 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"testing"
"time"
"gorm.io/gorm"
)
func TestConfigCleanupPreservesSharedDependencies(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
a.h.cleanNodeConfigs(1, `{
"services": [{"name":"70_1_0_tcp", "handler":{"chain":"chains_88"}, "limiter":"13, rule_traffic_limit_70"}],
"chains": [{"name":"fed_chain_17"}, {"name":"chains_88"}, {"name":"chains_999"}],
"limiters": [{"name":"13"}, {"name":"rule_traffic_limit_70"}, {"name":"99"}]
}`)
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("shared service was deleted: %+v", commands)
}
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_999")
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "99")
}
func TestConfigCleanupRemovesOrphanedForwardLimiter(t *testing.T) {
a := newCleanupAgent(t)
a.h.cleanNodeConfigs(1, `{"limiters":[{"name":"rule_traffic_limit_70"}]}`)
a.probe(t)
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "rule_traffic_limit_70")
}
func TestConfigCleanupProtectsPendingSharedDependencies(t *testing.T) {
for _, tc := range []struct {
name string
age time.Duration
keep bool
}{
{name: "pending", age: time.Minute, keep: true},
{name: "expired-reservation", age: 11 * time.Minute},
} {
t.Run(tc.name, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "", 1, 1, 0, time.Now().Add(-tc.age))
// Dependencies may arrive before the service and its runtime binding.
a.h.cleanNodeConfigs(1, `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`)
a.probe(t)
if tc.keep {
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
if commands := a.commandsOfType(commandType); len(commands) != 0 {
t.Fatalf("pending shared dependencies deleted: %+v", commands)
}
}
return
}
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_88")
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "13")
})
}
}
func TestConfigCleanupPreservesDependenciesOnLookupFailure(t *testing.T) {
for _, table := range []string{"peer_share_runtime", "tunnel", "speed_limit", "forward"} {
t.Run(table, func(t *testing.T) {
a := newCleanupAgent(t)
callback := "test:config-cleanup-query-failure"
injected := false
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == table {
injected = true
tx.AddError(errors.New("injected dependency lookup failure"))
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
configs := map[string]string{
"peer_share_runtime": `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`,
"tunnel": `{"chains":[{"name":"chains_88"}]}`,
"speed_limit": `{"limiters":[{"name":"13"}]}`,
"forward": `{"limiters":[{"name":"rule_traffic_limit_70"}]}`,
}
a.h.cleanNodeConfigs(1, configs[table])
a.probe(t)
if !injected {
t.Fatal("expected dependency lookup failure to be injected")
}
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
if commands := a.commandsOfType(commandType); len(commands) != 0 {
t.Fatalf("lookup failure must not authorize cleanup: %+v", commands)
}
}
})
}
}
func TestSharedForwardCleanupDuringRuntimeBinding(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "", 1, 1, 0, time.Now())
callback := "test:bind-during-cleanup"
bound := false
if err := a.h.repo.DB().Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table != "peer_share_runtime" || bound {
return
}
bound = true
// Reproduce binding immediately after the first ownership read.
// Separate name/unbound queries would both miss this runtime.
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET service_name = ?, applied = 1", "70_1_0").Error; err != nil {
t.Errorf("bind shared runtime: %v", err)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
if !bound {
t.Fatal("binding transition did not run")
}
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("service was deleted during binding: %+v", commands)
}
})
}
}
func assertCleanupDependency(t *testing.T, a *cleanupAgent, commandType, key, want string) {
t.Helper()
commands := a.commandsOfType(commandType)
if len(commands) != 1 {
t.Fatalf("expected one %s for %s, got %+v", commandType, want, commands)
}
var data map[string]string
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
t.Fatal(err)
}
if data[key] != want {
t.Fatalf("unexpected %s target: got %q, want %q", commandType, data[key], want)
}
}
@@ -1,118 +0,0 @@
package handler
import (
"log"
"strings"
"go-backend/internal/store/repo"
)
// Classify ownership before building local counters or enforcing local quotas.
// Numeric IDs from a consuming panel are not IDs in the provider's database.
func (h *Handler) buildNodeFlowUploadBatch(nodeID int64, items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
if h == nil || h.repo == nil || nodeID <= 0 {
return flowUploadBatch{}
}
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
log.Printf("flow ownership lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
log.Printf("flow resource lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
if err != nil {
log.Printf("flow node ownership lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
nodeForwards := make(map[int64]struct{}, len(forwardIDs))
for _, forwardID := range forwardIDs {
nodeForwards[forwardID] = struct{}{}
}
legacyOwners := make(map[string]map[int64]struct{})
addLegacyOwner := func(name string, shareID int64) {
name = normalizeForwardRuntimeServiceName(name)
if name == "" {
return
}
if legacyOwners[name] == nil {
legacyOwners[name] = make(map[int64]struct{})
}
legacyOwners[name][shareID] = struct{}{}
}
for _, runtime := range runtimes {
addLegacyOwner(runtime.ServiceName, runtime.ShareID)
}
resourceOwners := make(map[string]int64)
for _, resource := range resources {
// A migrated TCP service may still own a legacy UDP sibling. This alias
// remains authoritative until the whole legacy family is acknowledged gone.
addLegacyOwner(resource.LegacyServiceBase, resource.ShareID)
// A deletion intent does not prove the listener is gone. Failed or
// timed-out commands retain ownership until the node acknowledges it.
if resource.Kind == "service" && (resource.DesiredState != "deleted" || resource.Applied == 0) {
resourceOwners[resource.RuntimeName] = resource.ShareID
}
}
localItems := make([]flowItem, 0, len(items))
sharedUsage := make(map[int64]int64)
for _, item := range items {
name := strings.TrimSpace(item.N)
if strings.HasPrefix(name, "peer-share-") {
shareID, _, ok := parsePeerShareServiceName(name)
if ok && resourceOwners[name] == shareID && item.U >= 0 && item.D >= 0 {
sharedUsage[shareID] += item.U + item.D
}
// Unknown or confirmed-deleted scoped names must never become local IDs.
continue
}
if owners := legacyOwners[normalizeForwardRuntimeServiceName(name)]; len(owners) > 0 {
forwardID, userID, userTunnelID, parsed := parseFlowServiceIDs(name)
meta, local := metas[forwardID]
_, onNode := nodeForwards[forwardID]
if parsed && local && onNode && meta.UserID == userID && meta.UserTunnelID == userTunnelID {
// Legacy names can be genuinely ambiguous. Preserve the listener
// but do not debit either owner based on an ID guess.
log.Printf("ambiguous legacy flow ownership node_id=%d service=%s", nodeID, name)
continue
}
if len(owners) == 1 && item.U >= 0 && item.D >= 0 {
for shareID := range owners {
sharedUsage[shareID] += item.U + item.D
}
}
continue
}
if forwardID, userID, _, ok := parseFlowServiceIDs(name); ok {
if meta, exists := metas[forwardID]; exists {
if _, onNode := nodeForwards[forwardID]; !onNode || meta.UserID != userID {
continue
}
}
}
localItems = append(localItems, item)
}
batch := h.buildFlowUploadBatch(localItems, metas)
batch.peerShareUsage = sharedUsage
return batch
}
func (h *Handler) addPeerShareFlow(nodeID, shareID, delta int64) {
if nodeID <= 0 || shareID <= 0 || delta <= 0 {
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil || share.NodeID != nodeID {
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
return
}
share, err = h.repo.GetPeerShare(shareID)
if err == nil && isPeerShareFlowExceeded(share) {
h.enforcePeerShareFlowLimit(shareID)
}
}
@@ -1,235 +0,0 @@
package handler
import (
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"testing"
"time"
)
func TestFlowOwnershipSharedFlowDoesNotChargeLocalCollision(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 9, Name: "local", TrafficRatio: 1, Flow: 1, Type: 1, Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 2, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas), time.Now())
var local model.Forward
if err := a.h.repo.DB().First(&local, 70).Error; err != nil {
t.Fatal(err)
}
shared, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
t.Logf("local user=%d local bytes=%d shared bytes=%d", local.UserID, local.InFlow+local.OutFlow, shared.CurrentFlow)
if local.InFlow+local.OutFlow != 0 {
t.Errorf("shared flow charged colliding local forward: %d", local.InFlow+local.OutFlow)
}
if shared.CurrentFlow != 200 {
t.Errorf("shared flow missing: %d", shared.CurrentFlow)
}
}
func TestFlowOwnershipScopedServiceRequiresStoredNodeOwnership(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
name := peerShareResourceName(shareID, "service", "70_1_0_tcp")
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
RuntimeName: name, DesiredState: "active", Applied: 1,
}}); err != nil {
t.Fatal(err)
}
for _, nodeID := range []int64{2, 1} {
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: name, U: 120, D: 80}}, nil)
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
want := int64(0)
if nodeID == 1 {
want = 200
}
if share.CurrentFlow != want {
t.Fatalf("node=%d: got flow=%d want=%d", nodeID, share.CurrentFlow, want)
}
if len(batch.flowDeltas) != 0 || len(batch.quotaUsage) != 0 || len(batch.orphanServices) != 0 {
t.Fatalf("scoped traffic entered local accounting: %+v", batch)
}
}
a.probe(t)
if cmds := a.commandsOfType("DeleteService"); len(cmds) != 0 {
t.Fatalf("scoped service deleted: %+v", cmds)
}
}
func TestFlowOwnershipRoleRuntimeRequiresReportingNode(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "fed_svc_17", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET id = 17, role = 'exit'").Error; err != nil {
t.Fatal(err)
}
a.h.processFlowItem(2, flowItem{N: "fed_svc_17", U: 120, D: 80})
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 0 {
t.Fatalf("wrong node charged role runtime: %d", share.CurrentFlow)
}
a.h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 120, D: 80})
share, err = a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 200 {
t.Fatalf("owner node flow missing: %d", share.CurrentFlow)
}
}
func TestFlowOwnershipAmbiguousLegacyNameDoesNotDebitEitherOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 1, Port: 32000}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
batch := a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
a.h.applyFlowUploadBatch(1, batch, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 0 || len(batch.flowDeltas) != 0 {
t.Fatalf("ambiguous flow charged an owner: share=%d local=%+v", share.CurrentFlow, batch.flowDeltas)
}
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("ambiguous legacy service deleted: %+v", commands)
}
}
func TestFlowOwnershipPreservesUnmigratedLegacyTransport(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, peerShareResourceName(1, "service", "70_1_0"), 1, 1, 1, time.Now())
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
RuntimeName: peerShareResourceName(shareID, "service", "70_1_0_tcp"),
LegacyServiceBase: "70_1_0", DesiredState: "deleted", Applied: 1,
}}); err != nil {
t.Fatal(err)
}
a.h.processFlowItem(1, flowItem{N: "70_1_0_udp", U: 120, D: 80})
a.h.cleanNodeConfigs(1, `{"services":[{"name":"70_1_0_udp"}]}`)
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("unmigrated transport deleted: %+v", commands)
}
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 200 {
t.Fatalf("legacy transport accounting lost: %d", share.CurrentFlow)
}
}
func TestFlowOwnershipLocalForwardRequiresReportingNode(t *testing.T) {
a := newCleanupAgent(t)
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 2, Port: 32000}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
for _, nodeID := range []int64{1, 2} {
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
var forward model.Forward
if err := a.h.repo.DB().First(&forward, 70).Error; err != nil {
t.Fatal(err)
}
want := int64(0)
if nodeID == 2 {
want = 200
}
if forward.InFlow+forward.OutFlow != want {
t.Fatalf("node %d local traffic=%d want=%d", nodeID, forward.InFlow+forward.OutFlow, want)
}
}
}
func TestPeerShareMaintenanceRetriesOnlyUnfinishedOperations(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
name := peerShareResourceName(shareID, "service", "80_1_0_tcp")
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: shareID, NodeID: 1,
Kind: "service", OriginalName: "80_1_0_tcp", RuntimeName: name, DesiredState: "deleted", Applied: 0,
}}); err != nil {
t.Fatal(err)
}
a.h.retryPendingPeerShareOperations()
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
t.Fatalf("pending delete was not retried: %+v", commands)
}
a.h.retryPendingPeerShareOperations()
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
t.Fatalf("completed delete was replayed: %+v", commands)
}
ids, err := a.h.repo.ListPendingPeerShareNodeIDs()
if err != nil {
t.Fatal(err)
}
if len(ids) != 0 {
t.Fatalf("completed operation still pending: %v", ids)
}
}
func TestFlowOwnershipDoesNotCrossNodes(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
share.MaxBandwidth = 100
if err := a.h.repo.UpdatePeerShare(share); err != nil {
t.Fatal(err)
}
// Node 2 has no shared runtime. It reports the same local numeric service name.
a.h.applyFlowUploadBatch(2, a.h.buildNodeFlowUploadBatch(2, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, nil), time.Now())
a.probe(t)
share, err = a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
commands := a.commandsOfType("DeleteService")
t.Logf("node1 share charged from node2: %d; node1 delete commands: %+v", share.CurrentFlow, commands)
if share.CurrentFlow != 0 {
t.Errorf("flow crossed node boundary: %d", share.CurrentFlow)
}
if len(commands) != 0 {
t.Errorf("node2 flow deleted node1 shared service: %+v", commands)
}
}
@@ -1,57 +0,0 @@
package handler
import (
"testing"
"time"
)
func TestFlowOwnershipPendingDeletionRemainsBillableUntilAcknowledged(t *testing.T) {
for _, mode := range []string{"single", "batch"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "pending-deletion-"+mode)
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
name := peerShareResourceName(share.ID, "service", "70_1_0_tcp")
// Simulate a failed delivery after the deletion intent is committed. The
// mock node's previously installed listener has received no delete command.
disconnected := &Handler{repo: a.h.repo}
if got := resourceTestCommand(t, disconnected, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code == 0 {
t.Fatal("failed deletion reported success")
}
resource, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || resource == nil || resource.DesiredState != "deleted" || resource.Applied != 0 {
t.Fatalf("missing pending tombstone: %+v %v", resource, err)
}
report := func() {
item := flowItem{N: name, U: 120, D: 80}
if mode == "single" {
a.h.processFlowItem(1, item)
} else {
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{item}, nil), time.Now())
}
}
report()
stored, err := a.h.repo.GetPeerShare(share.ID)
if err != nil || stored.CurrentFlow != 200 {
t.Fatalf("unconfirmed deletion stopped billing: share=%+v err=%v", stored, err)
}
if len(a.commandsOfType("DeleteService")) != 0 {
t.Fatal("flow handling deleted a pending shared listener")
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
resource, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || resource.Applied != 1 {
t.Fatalf("deletion acknowledgment not recorded: %+v %v", resource, err)
}
report()
stored, err = a.h.repo.GetPeerShare(share.ID)
if err != nil || stored.CurrentFlow != 200 {
t.Fatalf("confirmed-deleted listener was billed again: share=%+v err=%v", stored, err)
}
})
}
}
+199 -245
View File
@@ -3,12 +3,13 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
@@ -47,22 +48,38 @@ type gostConfigSnapshot struct {
}
type namedConfigItem struct {
Name string `json:"name"`
Limiter string `json:"limiter,omitempty"`
Handler *struct {
Chain string `json:"chain"`
} `json:"handler,omitempty"`
Name string `json:"name"`
}
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
if h == nil || h.repo == nil || nodeID <= 0 {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
}
metas, err := h.repo.GetFlowUploadForwardMetas(collectFlowUploadForwardIDs([]flowItem{item}))
if err != nil {
metas = nil
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if ok {
if h.forwardExists(forwardID) {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
} else if nodeID > 0 {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
return
}
h.applyFlowUploadBatch(nodeID, h.buildNodeFlowUploadBatch(nodeID, []flowItem{item}, metas), time.Now())
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
if !ok {
return
}
h.processPeerShareFlow(runtimeID, item)
}
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
@@ -137,49 +154,73 @@ func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
return shareID, true
}
func (h *Handler) processPeerShareFlow(nodeID, runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || nodeID <= 0 || runtimeID <= 0 {
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
}
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
if err != nil || runtime == nil || runtime.NodeID != nodeID || runtime.Status != 1 {
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
return
}
h.addPeerShareFlow(nodeID, runtime.ShareID, item.D+item.U)
delta := item.D + item.U
if delta <= 0 {
return
}
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 || nodeID <= 0 {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
// Prefer the reporting node's explicit shared ownership over a coincidentally
// equal local forward ID. Never fall back to a service on another node.
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
delta := item.D + item.U
if delta <= 0 {
return
}
for _, runtime := range runtimes {
if normalizeForwardRuntimeServiceName(runtime.ServiceName) == normalizeForwardRuntimeServiceName(serviceName) {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
return
}
_, userID, _, ok := parseFlowServiceIDs(serviceName)
if !ok || userID != forward.UserID {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if ok {
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
@@ -194,26 +235,63 @@ func normalizeForwardRuntimeServiceName(serviceName string) string {
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || nodeID <= 0 || strings.TrimSpace(serviceName) == "" {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
delta := item.D + item.U
if delta <= 0 {
return
}
var shareID int64
for _, runtime := range runtimes {
if normalizeForwardRuntimeServiceName(runtime.ServiceName) != normalizeForwardRuntimeServiceName(serviceName) {
continue
}
if shareID != 0 {
log.Printf("ambiguous peer share runtime service=%s node_id=%d", serviceName, nodeID)
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
return
}
shareID = runtime.ShareID
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
}
if shareID > 0 {
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
@@ -221,8 +299,22 @@ func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
if err := h.cleanupPeerShareRuntimes(shareID); err != nil {
log.Printf("peer share quota cleanup pending share_id=%d err=%v", shareID, err)
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
}
@@ -394,48 +486,6 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
}, nil
}
// disableUserAccess retains unconfirmed forwards as active so a repeated disable
// can retry them. Login and tunnel access remain disabled when a node is offline.
func (h *Handler) disableUserAccess(userID int64, now int64) error {
permissionErr := h.repo.DisableUserTunnels(userID)
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return errors.Join(permissionErr, err)
}
errs := []error{permissionErr}
for i := range forwards {
forward := &forwards[i]
nftMode, entryNodeIDs, pauseErr := h.tunnelUsesNftables(forward.TunnelID)
if pauseErr == nil {
if nftMode {
// Exclude the rule from the desired plan before marking its pause
// confirmed. A failed SSH apply must remain eligible for retry.
pauseErr = h.syncNftablesNode(entryNodeIDs[0], forward.ID)
if pauseErr != nil {
pauseErr = h.nodeCommandError(entryNodeIDs[0], pauseErr)
}
} else {
// Missing services are already stopped. Self-healing would recreate
// services on every entry and could restart one already paused.
pauseErr = h.controlForwardServicesWithOptions(forward, "PauseService", forwardServiceControlOptions{
tolerateNotFound: true,
tolerateOffline: false,
allowSelfHeal: false,
continueOnError: true,
})
}
}
if pauseErr != nil {
errs = append(errs, fmt.Errorf("转发 %d 暂停失败: %w", forward.ID, pauseErr))
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
@@ -481,102 +531,43 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
return
}
protection, err := h.loadForwardServiceProtection(nodeID)
if err != nil {
return
}
h.cleanOrphanedServicesWithProtection(nodeID, snapshot.Services, protection)
// Dependencies are sent before services. A pending shared reservation may
// therefore have chains/limiters that are not referenced in this snapshot yet.
if protection.unbound {
return
}
chainsInUse := make(map[string]struct{})
limitersInUse := make(map[string]struct{})
for _, service := range snapshot.Services {
if service.Handler != nil {
if chain := strings.TrimSpace(service.Handler.Chain); chain != "" {
chainsInUse[chain] = struct{}{}
}
}
for _, limiter := range strings.Split(service.Limiter, ",") {
if limiter = strings.TrimSpace(limiter); limiter != "" {
limitersInUse[limiter] = struct{}{}
}
}
}
// Keep dependencies referenced by the reported services, even when their
// IDs belong to a different panel. Orphan dependencies can be collected on
// the next report after their services have actually disappeared.
h.cleanOrphanedChains(nodeID, snapshot.Chains, chainsInUse)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters, limitersInUse)
}
type forwardServiceProtection struct {
sharedNames map[string]struct{}
unbound bool
}
// Shared forward IDs belong to another panel and need not exist in our forward
// table. Use the same node-scoped ownership check for config and flow reports.
func (h *Handler) loadForwardServiceProtection(nodeID int64) (forwardServiceProtection, error) {
protection := forwardServiceProtection{sharedNames: make(map[string]struct{})}
// Read names and pending bindings in one snapshot, so a concurrent bind
// cannot fall between two queries and disappear from both protections.
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
return protection, err
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
for _, runtime := range runtimes {
serviceName := normalizeForwardRuntimeServiceName(runtime.ServiceName)
if serviceName == "" {
if runtime.Applied == 0 && runtime.UpdatedTime >= minUpdatedTime {
protection.unbound = true
}
continue
}
protection.sharedNames[serviceName] = struct{}{}
}
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
return protection, err
}
for _, resource := range resources {
if base := normalizeForwardRuntimeServiceName(resource.LegacyServiceBase); base != "" {
protection.sharedNames[base] = struct{}{}
}
}
return protection, nil
}
func (p forwardServiceProtection) preserves(serviceName string) bool {
_, shared := p.sharedNames[normalizeForwardRuntimeServiceName(serviceName)]
return shared || p.unbound
h.cleanOrphanedServices(nodeID, snapshot.Services)
h.cleanOrphanedChains(nodeID, snapshot.Chains)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
if h == nil || h.repo == nil || nodeID <= 0 {
return
}
protection, err := h.loadForwardServiceProtection(nodeID)
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
// A failed ownership lookup must never authorize deletion.
return
}
h.cleanOrphanedServicesWithProtection(nodeID, services, protection)
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
func (h *Handler) cleanOrphanedServicesWithProtection(nodeID int64, services []namedConfigItem, protection forwardServiceProtection) {
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") || strings.HasPrefix(name, "peer-share-") {
if strings.HasPrefix(name, "fed_svc_") {
continue
}
if _, ok := protection.sharedNames[normalizeForwardRuntimeServiceName(name)]; ok {
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
@@ -589,9 +580,15 @@ func (h *Handler) cleanOrphanedServicesWithProtection(nodeID int64, services []n
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); ok {
h.deleteOrphanedForwardService(nodeID, name, protection)
continue
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
}
}
suffix := parts[len(parts)-1]
@@ -610,17 +607,23 @@ func (h *Handler) cleanOrphanedServicesWithProtection(nodeID int64, services []n
}
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
base := strings.TrimSuffix(name, "_tcp")
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
}
}
}
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem, inUse map[string]struct{}) {
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
for _, item := range chains {
name := strings.TrimSpace(item.Name)
if name == "" || strings.HasPrefix(name, "fed_chain_") || strings.HasPrefix(name, "peer-share-") {
continue
}
if _, ok := inUse[name]; ok {
if name == "" {
continue
}
@@ -629,28 +632,17 @@ func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem, in
continue
}
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
if err != nil || tunnelID <= 0 {
continue
}
exists, err := h.repo.TunnelExists(tunnelID)
if err != nil || exists {
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
}
}
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem, inUse map[string]struct{}) {
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
for _, item := range limiters {
name := strings.TrimSpace(item.Name)
if name == "" || strings.HasPrefix(name, "peer-share-") {
continue
}
if _, ok := inUse[name]; ok {
continue
}
exists, err := h.lookupSpeedLimiter(name)
if err != nil || exists {
if name == "" || h.speedLimiterExists(name) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
@@ -668,78 +660,40 @@ func (h *Handler) forwardExists(forwardID int64) bool {
}
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
h.sendDeleteOrphanedForwardServices(nodeID, []string{serviceName})
}
func (h *Handler) sendDeleteOrphanedForwardServices(nodeID int64, serviceNames []string) {
if h == nil || h.repo == nil || nodeID <= 0 || len(serviceNames) == 0 {
return
}
protection, err := h.loadForwardServiceProtection(nodeID)
if err != nil {
return
}
seen := make(map[string]struct{}, len(serviceNames))
for _, serviceName := range serviceNames {
serviceName = normalizeForwardRuntimeServiceName(serviceName)
if _, ok := seen[serviceName]; ok {
continue
}
seen[serviceName] = struct{}{}
h.deleteOrphanedForwardService(nodeID, serviceName, protection)
}
}
func (h *Handler) deleteOrphanedForwardService(nodeID int64, serviceName string, protection forwardServiceProtection) {
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
if !ok || protection.preserves(serviceName) {
return
}
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || forwardID <= 0 {
return
}
base := parts[0] + "_" + parts[1] + "_" + parts[2]
// Parsing accepts legacy suffixes, while deletion targets the entire base
// family. Verify the actual targets cannot include a protected share.
if protection.preserves(base) {
return
}
// Batch metadata can be missing after a read failure, or stale by the time
// cleanup runs. Confirm absence before issuing a destructive command.
exists, err := h.repo.ForwardExists(forwardID)
if err != nil || exists {
return
}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
"services": buildForwardServiceDeleteNames([]string{base}),
"services": []string{base + "_tcp", base + "_udp"},
}, false, true)
}
func (h *Handler) speedLimiterExists(name string) bool {
exists, _ := h.lookupSpeedLimiter(name)
return exists
}
func (h *Handler) lookupSpeedLimiter(name string) (bool, error) {
name = strings.TrimSpace(name)
if name == "" {
return false, nil
return false
}
const forwardRulePrefix = "rule_traffic_limit_"
if strings.HasPrefix(name, forwardRulePrefix) {
forwardID, err := strconv.ParseInt(strings.TrimPrefix(name, forwardRulePrefix), 10, 64)
if err != nil || forwardID <= 0 {
return false, nil
return false
}
forward, err := h.getForwardRecord(forwardID)
if errors.Is(err, errForwardNotFound) {
return false, nil
}
return forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0, err
return err == nil && forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0
}
id, err := strconv.ParseInt(name, 10, 64)
if err != nil || id <= 0 {
return false, nil
return false
}
return h.repo.SpeedLimitExists(id)
ok, _ := h.repo.SpeedLimitExists(id)
return ok
}
@@ -10,8 +10,11 @@ import (
)
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
a := newCleanupAgent(t)
r := a.h.repo
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
@@ -40,7 +43,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := a.h
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := r.GetPeerShare(share.ID)
@@ -117,9 +120,6 @@ func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T
}
h := &Handler{repo: r}
if err := r.DB().Exec("INSERT INTO forward_port(forward_id, node_id, port) VALUES(20, 1, 30001)").Error; err != nil {
t.Fatal(err)
}
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
@@ -23,7 +23,6 @@ type flowUploadBatch struct {
orphanServices map[string]struct{}
peerShareForwardItems map[string]flowItem
peerShareRuntimeItems map[int64]flowItem
peerShareUsage map[int64]int64
}
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
@@ -66,9 +65,6 @@ func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.Fl
batch.orphanServices[serviceName] = struct{}{}
continue
}
// Local accounting uses database ownership, not foreign IDs embedded in
// a service name (including stale user-tunnel IDs after reassignment).
userID, userTunnelID = meta.UserID, meta.UserTunnelID
raw := batch.forwardTraffic[forwardID]
raw.bytesIn += item.D
@@ -124,12 +120,8 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
}
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
}
if len(batch.orphanServices) > 0 {
serviceNames := make([]string, 0, len(batch.orphanServices))
for serviceName := range batch.orphanServices {
serviceNames = append(serviceNames, serviceName)
}
h.sendDeleteOrphanedForwardServices(nodeID, serviceNames)
for serviceName := range batch.orphanServices {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
}
for serviceName, item := range batch.peerShareForwardItems {
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
@@ -138,10 +130,7 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
}
}
for runtimeID, item := range batch.peerShareRuntimeItems {
h.processPeerShareFlow(nodeID, runtimeID, item)
}
for shareID, delta := range batch.peerShareUsage {
h.addPeerShareFlow(nodeID, shareID, delta)
h.processPeerShareFlow(runtimeID, item)
}
}
@@ -14,8 +14,6 @@ func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t
metas := map[int64]repo.FlowUploadForwardMeta{
20: {
ForwardID: 20,
UserID: 2,
UserTunnelID: 10,
TunnelID: 1,
TrafficRatio: 2,
TunnelFlow: 3,
+9 -21
View File
@@ -39,11 +39,8 @@ type Handler struct {
healthCheck *health.Checker
nftablesManager nftablesRuntimeManager
peerResourceMu sync.Mutex
captchaMu sync.Mutex
captchaTokens map[string]int64
passkeyMu sync.Mutex
passkeyPending map[string]passkeyCeremony
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
@@ -52,13 +49,12 @@ type Handler struct {
fingerprintMu sync.Mutex
licenseValidationMu sync.Mutex
upgradeMu sync.Mutex
systemUpgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
nodeOnlineRedeployAt map[int64]time.Time
nodeOnlineRedeployQueued map[int64]struct{}
nodeOnlineRedeploying map[int64]struct{}
nodeLocalRuntimeRetryQueued map[int64]struct{}
upgradeMu sync.Mutex
systemUpgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
nodeOnlineRedeployAt map[int64]time.Time
nodeOnlineRedeployQueued map[int64]struct{}
nodeOnlineRedeploying map[int64]struct{}
qualityProber *tunnelQualityProber
bestExit *bestExitManager
@@ -118,7 +114,6 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
healthCheck: nil,
nftablesManager: runtimenft.NewManager(nil),
captchaTokens: make(map[string]int64),
passkeyPending: make(map[string]passkeyCeremony),
pendingUpgradeRedeploy: make(map[int64]struct{}),
nodeOnlineRedeployAt: make(map[int64]time.Time),
nodeOnlineRedeployQueued: make(map[int64]struct{}),
@@ -160,13 +155,6 @@ func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/login", h.login)
mux.HandleFunc("/api/v1/user/passkey/status", h.passkeyStatus)
mux.HandleFunc("/api/v1/user/passkey/login/begin", h.passkeyLoginBegin)
mux.HandleFunc("/api/v1/user/passkey/login/finish", h.passkeyLoginFinish)
mux.HandleFunc("/api/v1/user/passkey/register/begin", h.passkeyRegisterBegin)
mux.HandleFunc("/api/v1/user/passkey/register/finish", h.passkeyRegisterFinish)
mux.HandleFunc("/api/v1/user/passkey/list", h.passkeyList)
mux.HandleFunc("/api/v1/user/passkey/delete", h.passkeyDelete)
mux.HandleFunc("/api/v1/user/list", h.userList)
mux.HandleFunc("/api/v1/user/create", h.userCreate)
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
@@ -888,7 +876,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
metas = map[int64]repo.FlowUploadForwardMeta{}
}
batch := h.buildNodeFlowUploadBatch(node.ID, items, metas)
batch := h.buildFlowUploadBatch(items, metas)
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
h.applyFlowUploadBatch(node.ID, batch, now)
}
+1 -44
View File
@@ -21,7 +21,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(9)
h.jobsWG.Add(8)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -32,49 +32,6 @@ func (h *Handler) StartBackgroundJobs() {
go h.runTunnelQualityProber(ctx)
go h.runValidateLicenseJob(ctx)
go h.runNftablesTrafficCollectLoop(ctx)
go h.runFederationCleanupRetryLoop(ctx)
}
func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
defer h.jobsWG.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
}
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
log.Printf("federation cleanup remains pending: %v", err)
}
h.retryPendingPeerShareOperations()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (h *Handler) retryPendingPeerShareOperations() {
nodeIDs, err := h.repo.ListPendingPeerShareNodeIDs()
if err != nil {
log.Printf("peer share pending operation lookup failed: %v", err)
return
}
for _, nodeID := range nodeIDs {
node, err := h.repo.GetNodeByID(nodeID)
if err != nil || node == nil || node.Status != 1 {
continue
}
if err := h.retryPendingPeerShareResourcesOnNode(nodeID); err != nil {
log.Printf("peer share resource retry failed node_id=%d err=%v", nodeID, err)
}
if err := h.retryPendingPeerShareRoleRuntimesOnNode(nodeID); err != nil {
log.Printf("peer share role retry failed node_id=%d err=%v", nodeID, err)
}
}
}
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
+109 -308
View File
@@ -205,15 +205,6 @@ 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)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
@@ -252,13 +243,6 @@ 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 {
warnings, syncErr := h.syncUserMaxConnForwards(id)
if syncErr != nil {
@@ -843,10 +827,6 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateTunnelRuntimeBeforeApply(runtimeState); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if len(runtimeState.InNodes) > 0 {
firstNodeID := runtimeState.InNodes[0].NodeID
@@ -929,27 +909,22 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
var federationReleaseRefs []federationRuntimeReleaseRef
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
if err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
applyTunnelPortsToRequest(req, runtimeState)
if err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -957,11 +932,8 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
if cleanupErr := h.cleanupFederationRuntime(tunnelID); cleanupErr != nil {
applyErr = errors.Join(applyErr, cleanupErr)
} else {
_ = h.deleteTunnelByID(tunnelID)
}
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
return
}
@@ -1119,38 +1091,22 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
probeTargetPort = probeTarget.Port
}
}
oldEntryNodeIDs, err := h.tunnelEntryNodeIDs(id)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
oldTunnel, err := h.getTunnelRecord(id)
if err != nil || oldTunnel == nil {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
oldTunnel, _ := h.getTunnelRecord(id)
if !probeTargetFieldsPresent && oldTunnel != nil {
probeTargetHost = oldTunnel.ProbeTargetHost
probeTargetPort = oldTunnel.ProbeTargetPort
}
oldChainRows, err := h.listChainNodesForTunnel(id)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
oldChainRows, _ := h.listChainNodesForTunnel(id)
if oldTunnel != nil && oldTunnel.Type == 2 && typeVal != 2 {
h.cleanupTunnelRuntime(id)
}
h.cleanupFederationRuntime(id)
now := time.Now().UnixMilli()
localDomain := h.federationLocalDomain()
// Check the complete request and known database constraints before changing
// any existing listener or remote binding.
validationTx := h.repo.BeginTx()
if validationTx.Error != nil {
response.WriteJSON(w, response.Err(-2, validationTx.Error.Error()))
return
}
defer validationTx.Rollback()
runtimeState, err := h.prepareTunnelCreateState(validationTx, req, typeVal, id)
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
@@ -1163,55 +1119,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
entryNodeIDs = append(entryNodeIDs, inNode.NodeID)
}
}
if err := h.validateNftablesTunnelStateTx(validationTx, entryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(validationTx, id, oldEntryNodeIDs, entryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateTunnelRuntimeBeforeApply(runtimeState); err != nil {
if err := h.validateNftablesTunnelState(entryNodeIDs); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)
updateProtocol := "tls"
if len(runtimeState.OutNodes) > 0 && strings.TrimSpace(runtimeState.OutNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.OutNodes[0].Protocol)
} else if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
if err := h.repo.UpdateTunnelTx(validationTx, id, asString(req["name"]), typeVal,
asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1),
inIp, ipPreference, updateProtocol, probeTargetHost, probeTargetPort, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.validateTunnelChainReplacementTx(validationTx, runtimeState); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := validationTx.Rollback().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.cleanupFederationRuntime(id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if oldTunnel.Type == 2 && typeVal != 2 {
h.cleanupTunnelRuntime(id)
}
var federationBindings []repo.FederationTunnelBinding
var federationReleaseRefs []federationRuntimeReleaseRef
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)
if err != nil {
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
@@ -1219,13 +1137,18 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
tx := h.repo.BeginTx()
if tx.Error != nil {
tx.Rollback()
err := errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, tx.Error.Error()))
return
}
defer func() { tx.Rollback() }()
updateProtocol := "tls"
if len(runtimeState.OutNodes) > 0 && strings.TrimSpace(runtimeState.OutNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.OutNodes[0].Protocol)
} else if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
if err := h.repo.UpdateTunnelTx(
tx,
id,
@@ -1241,27 +1164,21 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
probeTargetPort,
now,
); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.replaceTunnelChainsTx(tx, id, req); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.ReplaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1273,15 +1190,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
}
}
if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
err = errors.Join(err, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1306,7 +1220,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
if oldTunnel == nil || oldTunnel.Type != 2 {
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
}
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(id))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
response.WriteJSON(w, response.OKEmpty())
return
@@ -1515,10 +1430,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, t
}
forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID)
if err != nil {
return err
}
if len(forwards) == 0 {
if err != nil || len(forwards) == 0 {
return nil
}
@@ -1529,7 +1441,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, t
}
oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID)
if portsErr != nil {
return portsErr
continue
}
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
@@ -1539,7 +1451,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, t
for _, nodeID := range addedNodeIDs {
node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID)
if nodeErr != nil {
return nodeErr
continue
}
if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil {
@@ -1691,11 +1603,8 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
if err := h.cleanupFederationRuntime(id); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
h.cleanupTunnelRuntime(id)
h.cleanupFederationRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1762,12 +1671,8 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
if err := h.cleanupFederationRuntime(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
continue
}
h.cleanupTunnelRuntime(id)
h.cleanupFederationRuntime(id)
if err := h.deleteTunnelByID(id); err != nil {
fail++
failures = appendBatchFailure(failures, id, tunnelName, err)
@@ -1866,35 +1771,34 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
return err
}
if err := validateTunnelRuntimeBeforeApply(state); err != nil {
return err
}
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
return err
}
h.cleanupTunnelRuntime(tunnelID)
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
if fedErr != nil {
return errors.Join(fedErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
return fedErr
}
tx := h.repo.BeginTx()
if tx.Error != nil {
return errors.Join(tx.Error, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return tx.Error
}
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
tx.Rollback()
return errors.Join(replaceErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return replaceErr
}
if commitErr := tx.Commit().Error; commitErr != nil {
return errors.Join(commitErr, h.releaseFederationRuntimeRefs(federationReleaseRefs))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return commitErr
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
applyErr = errors.Join(applyErr, h.cleanupFederationRuntime(tunnelID))
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
return applyErr
}
}
@@ -3489,13 +3393,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
existingNodeIDs := make(map[int64]struct{})
if excludeTunnelID > 0 {
var existIDs []int64
var err error
existIDs, err = h.repo.ListTunnelChainNodeIDsTx(tx, excludeTunnelID)
if err != nil {
return nil, err
}
for _, eid := range existIDs {
existingNodeIDs[eid] = struct{}{}
if err := tx.Model(&model.ChainTunnel{}).
Where("tunnel_id = ?", excludeTunnelID).
Pluck("node_id", &existIDs).Error; err == nil {
for _, eid := range existIDs {
existingNodeIDs[eid] = struct{}{}
}
}
}
@@ -3543,59 +3446,6 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
return state, nil
}
func validateTunnelRuntimeBeforeApply(state *tunnelCreateState) error {
if state.Type != 2 {
return nil
}
for _, node := range state.Nodes {
if node.IsRemote == 1 && (strings.TrimSpace(node.RemoteURL) == "" || strings.TrimSpace(node.RemoteToken) == "") {
return fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
}
}
groups := append([][]tunnelRuntimeNode{state.InNodes}, state.ChainHops...)
groups = append(groups, state.OutNodes)
for i := 0; i+1 < len(groups); i++ {
for _, source := range groups[i] {
for _, target := range groups[i+1] {
node := state.Nodes[target.NodeID]
if node == nil {
return errors.New("节点不存在")
}
if _, err := selectTunnelDialHost(state.Nodes[source.NodeID], node, state.IPPreference, target.ConnectIP); err != nil {
return err
}
if node.IsRemote != 1 && target.Port <= 0 {
return errors.New("节点端口不能为空")
}
}
}
}
return nil
}
// Exercise known chain write constraints inside the validation transaction.
// Remote ports that have not been reserved yet remain NULL until actual apply.
func (h *Handler) validateTunnelChainReplacementTx(tx *gorm.DB, state *tunnelCreateState) error {
if err := h.repo.DeleteChainTunnelsByTunnelTx(tx, state.TunnelID); err != nil {
return err
}
groups := append([][]tunnelRuntimeNode{state.InNodes, state.OutNodes}, state.ChainHops...)
for groupIndex, group := range groups {
for nodeIndex, node := range group {
inx := nodeIndex + 1
if node.ChainType == 2 {
inx = groupIndex - 1
}
port := sql.NullInt64{Int64: int64(node.Port), Valid: node.Port > 0}
if err := h.repo.CreateChainTunnelTx(tx, state.TunnelID, strconv.Itoa(node.ChainType),
node.NodeID, port, node.Strategy, inx, node.Protocol, node.ConnectIP); err != nil {
return err
}
}
}
return nil
}
func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {
set := make(map[string]struct{})
ordered := make([]string, 0)
@@ -3745,7 +3595,8 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
remoteURL := strings.TrimSpace(node.RemoteURL)
remoteToken := strings.TrimSpace(node.RemoteToken)
if remoteURL == "" || remoteToken == "" {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
}
resourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)
@@ -3760,14 +3611,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
}
if err != nil {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
}
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
RemoteURL: remoteURL, RemoteToken: remoteToken, BindingID: reserveRes.BindingID,
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
})
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
outNode = state.OutNodes[outIdx]
@@ -3780,7 +3627,8 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
}
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
if err != nil {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
}
if applyRes.AllocatedPort > 0 {
state.OutNodes[outIdx].Port = applyRes.AllocatedPort
@@ -3800,7 +3648,13 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
CreatedTime: now,
UpdatedTime: now,
})
releaseRefs[len(releaseRefs)-1].BindingID = defaultString(applyRes.BindingID, reserveRes.BindingID)
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
RemoteURL: remoteURL,
RemoteToken: remoteToken,
BindingID: applyRes.BindingID,
ReservationID: reserveRes.ReservationID,
ResourceKey: resourceKey,
})
}
for hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {
@@ -3813,7 +3667,8 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
remoteURL := strings.TrimSpace(node.RemoteURL)
remoteToken := strings.TrimSpace(node.RemoteToken)
if remoteURL == "" || remoteToken == "" {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
}
resourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)
@@ -3828,14 +3683,10 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
}
if err != nil {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
}
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
RemoteURL: remoteURL, RemoteToken: remoteToken, BindingID: reserveRes.BindingID,
ReservationID: reserveRes.ReservationID, ResourceKey: resourceKey,
})
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
chainNode = state.ChainHops[hopIdx][nodeIdx]
@@ -3849,14 +3700,17 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
for _, target := range nextTargets {
targetNode := state.Nodes[target.NodeID]
if targetNode == nil {
return bindings, releaseRefs, errors.New("节点不存在")
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, errors.New("节点不存在")
}
host, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)
if hostErr != nil {
return bindings, releaseRefs, hostErr
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, hostErr
}
if target.Port <= 0 {
return bindings, releaseRefs, errors.New("节点端口不能为空")
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, errors.New("节点端口不能为空")
}
applyTargets = append(applyTargets, client.RuntimeTarget{
Host: host,
@@ -3875,7 +3729,8 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
}
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
if err != nil {
return bindings, releaseRefs, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
h.releaseFederationRuntimeRefs(releaseRefs)
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
}
if applyRes.AllocatedPort > 0 {
state.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort
@@ -3895,124 +3750,70 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
CreatedTime: now,
UpdatedTime: now,
})
releaseRefs[len(releaseRefs)-1].BindingID = defaultString(applyRes.BindingID, reserveRes.BindingID)
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
RemoteURL: remoteURL,
RemoteToken: remoteToken,
BindingID: applyRes.BindingID,
ReservationID: reserveRes.ReservationID,
ResourceKey: resourceKey,
})
}
}
return bindings, releaseRefs, nil
}
// releaseFederationRuntimeRefs is used after the caller has rolled back its
// transaction. Failed releases survive process restarts in a separate queue.
func (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) error {
func (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {
if h == nil || len(refs) == 0 {
return nil
return
}
fc := client.NewFederationClient()
localDomain := h.federationLocalDomain()
var failures []error
for i := len(refs) - 1; i >= 0; i-- {
ref := refs[i]
pending := &model.FederationPendingRelease{
RemoteURL: ref.RemoteURL, RemoteToken: ref.RemoteToken,
BindingID: ref.BindingID, ReservationID: ref.ReservationID, ResourceKey: ref.ResourceKey,
}
if err := h.repo.SavePendingFederationRelease(pending); err != nil {
failures = append(failures, fmt.Errorf("保存共享运行时清理任务失败: %w", err))
if strings.TrimSpace(ref.RemoteURL) == "" || strings.TrimSpace(ref.RemoteToken) == "" {
continue
}
req := client.RuntimeReleaseRoleRequest{
BindingID: ref.BindingID, ReservationID: ref.ReservationID, ResourceKey: ref.ResourceKey,
}
if err := fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req); err != nil {
failures = append(failures, err)
continue
}
if err := h.repo.DeletePendingFederationRelease(pending.ID); err != nil {
failures = append(failures, err)
BindingID: ref.BindingID,
ReservationID: ref.ReservationID,
ResourceKey: ref.ResourceKey,
}
_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)
}
return errors.Join(failures...)
}
func (h *Handler) cleanupFederationRuntime(tunnelID int64) error {
func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
if h == nil || tunnelID <= 0 {
return nil
return
}
bindings, err := h.repo.ListFederationTunnelBindingsForCleanup(tunnelID)
if err != nil {
return err
bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
if err != nil || len(bindings) == 0 {
return
}
var failures []error
for _, b := range bindings {
// Persist intent before sending the request: timeouts can occur after
// the peer has already acted, and must remain safely retryable.
if err := h.repo.MarkFederationTunnelBindingPendingRelease(b.ID); err != nil {
failures = append(failures, err)
continue
}
if err := h.releaseFederationTunnelBinding(b); err != nil {
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
func (h *Handler) releaseFederationTunnelBinding(b repo.FederationTunnelBinding) error {
node, err := h.repo.GetNodeByID(b.NodeID)
if err != nil {
return err
}
if node == nil {
return fmt.Errorf("共享节点 %d 不存在,保留待清理绑定", b.NodeID)
}
remoteURL := strings.TrimSpace(b.RemoteURL)
if remoteURL == "" {
remoteURL = strings.TrimSpace(node.RemoteURL.String)
}
remoteToken := strings.TrimSpace(node.RemoteToken.String)
if remoteURL == "" || remoteToken == "" {
return fmt.Errorf("共享节点 %d 缺少连接配置,保留待清理绑定", b.NodeID)
}
req := client.RuntimeReleaseRoleRequest{
BindingID: strings.TrimSpace(b.RemoteBindingID), ResourceKey: strings.TrimSpace(b.ResourceKey),
}
if err := client.NewFederationClient().ReleaseRole(remoteURL, remoteToken, h.federationLocalDomain(), req); err != nil {
return fmt.Errorf("共享节点 %d 清理失败: %w", b.NodeID, err)
}
return h.repo.DeleteFederationTunnelBinding(b.ID)
}
// retryPendingFederationRuntimeCleanup never touches active bindings.
func (h *Handler) retryPendingFederationRuntimeCleanup() error {
bindings, err := h.repo.ListPendingFederationTunnelBindings()
if err != nil {
return err
}
var failures []error
for _, binding := range bindings {
if err := h.releaseFederationTunnelBinding(binding); err != nil {
failures = append(failures, err)
}
}
pending, err := h.repo.ListPendingFederationReleases()
if err != nil {
return errors.Join(append(failures, err)...)
}
fc := client.NewFederationClient()
for _, item := range pending {
req := client.RuntimeReleaseRoleRequest{
BindingID: item.BindingID, ReservationID: item.ReservationID, ResourceKey: item.ResourceKey,
}
if err := fc.ReleaseRole(item.RemoteURL, item.RemoteToken, h.federationLocalDomain(), req); err != nil {
failures = append(failures, err)
localDomain := h.federationLocalDomain()
for _, b := range bindings {
node, nodeErr := h.repo.GetNodeByID(b.NodeID)
if nodeErr != nil || node == nil {
continue
}
if err := h.repo.DeletePendingFederationRelease(item.ID); err != nil {
failures = append(failures, err)
remoteURL := strings.TrimSpace(node.RemoteURL.String)
if remoteURL == "" {
remoteURL = strings.TrimSpace(b.RemoteURL)
}
remoteToken := strings.TrimSpace(node.RemoteToken.String)
if remoteURL == "" || remoteToken == "" {
continue
}
req := client.RuntimeReleaseRoleRequest{
BindingID: strings.TrimSpace(b.RemoteBindingID),
ResourceKey: strings.TrimSpace(b.ResourceKey),
}
_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)
}
return errors.Join(failures...)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
}
func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {
@@ -7,7 +7,6 @@ import (
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
@@ -151,7 +150,7 @@ func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int6
return nil
}
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
func (h *Handler) buildNftablesNodePlan(nodeID int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
@@ -163,9 +162,6 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int6
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
for i := range forwards {
forward := &forwards[i]
if slices.Contains(excludedForwardIDs, forward.ID) {
continue
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil || tunnel.Status != 1 {
continue
@@ -205,11 +201,11 @@ func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int6
return plan, cfg, nil
}
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
func (h *Handler) syncNftablesNode(nodeID int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID)
if err != nil {
return err
}
-440
View File
@@ -1,440 +0,0 @@
package handler
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/model"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyTTL = 2 * time.Minute
type passkeyCeremony struct {
userID int64
kind string
origin string
session webauthn.SessionData
}
type passkeyUser struct {
id int64
name string
credentials []webauthn.Credential
}
func (u passkeyUser) WebAuthnID() []byte {
id := make([]byte, 8)
binary.BigEndian.PutUint64(id, uint64(u.id))
return id
}
func (u passkeyUser) WebAuthnName() string { return u.name }
func (u passkeyUser) WebAuthnDisplayName() string { return u.name }
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func passkeyConfig() (*webauthn.WebAuthn, string) {
raw := strings.TrimSpace(os.Getenv("FLVX_WEBAUTHN_ORIGIN"))
u, err := url.Parse(raw)
if err != nil || u == nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, ""
}
host := u.Hostname()
if host == "" || strings.ContainsAny(host, " /\\") || (u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1"))) {
return nil, ""
}
origin := u.Scheme + "://" + u.Host
wa, err := webauthn.New(&webauthn.Config{
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
},
})
if err != nil {
return nil, ""
}
return wa, origin
}
func (h *Handler) passkeyStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, _ := passkeyConfig()
response.WriteJSON(w, response.OK(map[string]bool{"enabled": wa != nil}))
}
func (h *Handler) putPasskeyCeremony(c passkeyCeremony) (string, bool) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false
}
id := base64.RawURLEncoding.EncodeToString(buf)
h.passkeyMu.Lock()
defer h.passkeyMu.Unlock()
for k, v := range h.passkeyPending {
if time.Now().After(v.session.Expires) {
delete(h.passkeyPending, k)
}
}
if len(h.passkeyPending) >= 1000 {
return "", false
}
h.passkeyPending[id] = c
return id, true
}
func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (webauthn.SessionData, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != kind || c.origin != origin || c.userID != userID || time.Now().After(c.session.Expires) {
return webauthn.SessionData{}, false
}
return c.session, true
}
func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) {
return passkeyCeremony{}, false
}
return c, true
}
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
return passkeyUser{}, errInvalidPasskey
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
return passkeyUser{}, err
}
u := passkeyUser{id: user.ID, name: user.User}
for _, row := range rows {
var credential webauthn.Credential
if err := json.Unmarshal([]byte(row.CredentialJSON), &credential); err != nil {
return passkeyUser{}, err
}
u.credentials = append(u.credentials, credential)
}
return u, nil
}
var errInvalidPasskey = &passkeyError{}
type passkeyError struct{}
func (*passkeyError) Error() string { return "invalid passkey user" }
func passkeyBody(r *http.Request, out interface{}) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(out) == nil
}
func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
Password string `json:"password"`
}
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: userID, kind: "register", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
type passkeyFinishRequest struct {
SessionID string `json:"sessionId"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name"`
}
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
session, ok := h.takePasskeyCeremony(req.SessionID, "register", origin, userID)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
credential, err := wa.FinishRegistration(u, session, credentialRequest(r, req.Credential))
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
name := strings.TrimSpace(req.Name)
if len([]rune(name)) > 100 {
name = string([]rune(name)[:100])
}
if name == "" {
name = "Passkey"
}
err = h.repo.CreatePasskey(&model.Passkey{ID: base64.RawURLEncoding.EncodeToString(credential.ID), UserID: userID, Name: name, CredentialJSON: string(credentialJSON), CreatedAt: time.Now().UnixMilli()})
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
response.WriteJSON(w, response.OK(nil))
}
func credentialRequest(original *http.Request, body []byte) *http.Request {
r := original.Clone(original.Context())
r.Body = http.NoBody
if len(body) > 0 {
r.Body = ioNopCloser{bytes.NewReader(body)}
}
return r
}
type ioNopCloser struct{ *bytes.Reader }
func (ioNopCloser) Close() error { return nil }
func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
// The credential ID determines ownership. A supplied userHandle cannot select another account.
if len(rawID) == 0 || len(userHandle) != 8 {
return nil, errInvalidPasskey
}
row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID))
if lookupErr != nil || row == nil {
return nil, errInvalidPasskey
}
owner, loadErr := h.loadPasskeyUser(row.UserID)
if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) {
return nil, errInvalidPasskey
}
return owner, nil
}, pending.session, credentialRequest(r, req.Credential))
var u passkeyUser
if err == nil {
var valid bool
u, valid = resolved.(passkeyUser)
if !valid {
err = errInvalidPasskey
}
}
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
stored, err := h.repo.GetPasskey(u.id, credentialID)
if err != nil || stored == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
updated, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
ok, err = h.repo.UpdatePasskeyCredential(u.id, credentialID, stored.CredentialJSON, string(updated), time.Now().UnixMilli())
if err != nil || !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
user, err := h.repo.GetUserByID(u.id)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, time.Now())
if err != nil {
response.WriteJSON(w, response.ErrDefault("登录失败"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"token": token, "name": user.User, "role_id": user.RoleID, "requirePasswordChange": user.User == "admin_user"}))
}
func (h *Handler) passkeyList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("获取通行证密钥失败"))
return
}
out := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
out = append(out, map[string]interface{}{"id": row.ID, "name": row.Name, "createdAt": row.CreatedAt, "lastUsedAt": row.LastUsedAt})
}
response.WriteJSON(w, response.OK(out))
}
func (h *Handler) passkeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if !passkeyBody(r, &req) || req.ID == "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
deleted, err := h.repo.DeletePasskey(userID, req.ID)
if err != nil || !deleted {
response.WriteJSON(w, response.ErrDefault("通行证密钥不存在"))
return
}
response.WriteJSON(w, response.OK(nil))
}
@@ -1,56 +0,0 @@
package handler
import (
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create challenge")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired challenge")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("expired challenge was accepted")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok {
t.Fatal("wrong origin was accepted")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("failed discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok {
t.Fatal("valid discoverable challenge was rejected")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("expired discoverable challenge was accepted")
}
}
@@ -1,160 +0,0 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestPeerShareRestrictedShareAllowsOnlyAuthenticatedCleanup(t *testing.T) {
for _, state := range []string{"disabled", "expired", "over-quota"} {
t.Run(state, func(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
changes := map[string]interface{}{"allowed_ips": "203.0.113.10", "allowed_domains": "owner.example"}
switch state {
case "disabled":
changes["is_active"] = 0
case "expired":
changes["expiry_time"] = time.Now().Add(-time.Hour).UnixMilli()
case "over-quota":
changes["max_bandwidth"] = 1
changes["current_flow"] = 2
}
if err := agent.h.repo.DB().Model(&repo.PeerShare{}).Where("id = ?", runtime.ShareID).Updates(changes).Error; err != nil {
t.Fatal(err)
}
tests := []struct {
command string
allowed bool
}{
{"release-role", true}, {"DeleteService", true}, {"DeleteChains", true}, {"DeleteLimiters", true}, {"DeleteCLimiters", true},
{"AddService", false}, {"UpdateService", false}, {"ResumeService", false}, {"PauseService", false}, {"DeleteEverything", false},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
path := "/api/v1/federation/runtime/command"
body := fmt.Sprintf(`{"commandType":%q,"data":{"services":["70_1_0_tcp"]}}`, test.command)
if test.command == "release-role" {
path = "/api/v1/federation/runtime/release-role"
body = `{"reservationId":"role-reservation"}`
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
reached := false
next := func(w http.ResponseWriter, r *http.Request) {
reached = true
received, err := io.ReadAll(r.Body)
if err != nil || string(received) != body {
t.Errorf("auth consumed request body: %s %v", received, err)
}
response.WriteJSON(w, response.OKEmpty())
}
agent.h.authPeer(next)(httptest.NewRecorder(), req)
if reached != test.allowed {
t.Fatalf("restricted %s %s allowed=%t want=%t", state, test.command, reached, test.allowed)
}
})
}
for _, invalid := range []string{"token", "domain", "ip"} {
t.Run("reject-"+invalid, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
switch invalid {
case "token":
req.Header.Set("Authorization", "Bearer wrong-token")
case "domain":
req.Header.Set("X-Panel-Domain", "other.example")
case "ip":
req.RemoteAddr = "198.51.100.1:12345"
}
reached := false
agent.h.authPeer(func(http.ResponseWriter, *http.Request) { reached = true })(httptest.NewRecorder(), req)
if reached {
t.Fatalf("cleanup bypassed %s authentication", invalid)
}
})
}
// Exercise the actual release handler through auth, not only the gate.
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
res := httptest.NewRecorder()
agent.h.authPeer(agent.h.federationRuntimeReleaseRole)(res, req)
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
t.Fatalf("authenticated cleanup did not complete: %s %v", res.Body.String(), err)
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 {
t.Fatalf("cleanup did not release runtime: %+v %v", stored, err)
}
})
}
}
func TestPeerShareOldReleaseIdentityCannotDeleteReusedReservation(t *testing.T) {
agent := newCleanupAgent(t)
old := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.releasePeerShareRuntime(old); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", strings.NewReader(`{"resourceKey":"role-resource","requestedPort":31000,"protocol":"tls"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
res := httptest.NewRecorder()
agent.h.federationRuntimeReservePort(res, req)
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
t.Fatalf("new generation reserve failed: %s %v", res.Body.String(), err)
}
fresh, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil {
t.Fatal(err)
}
if fresh.ReservationID == old.ReservationID {
t.Fatal("reservation identity was reused")
}
if code := roleRuntimeRequest(t, agent.h, fmt.Sprintf(`{"reservationId":%q,"role":"exit"}`, fresh.ReservationID), false); code != 0 {
t.Fatal("new generation apply failed")
}
fresh, err = agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil {
t.Fatal(err)
}
if fresh.BindingID == old.BindingID || fresh.BindingID == "" {
t.Fatal("binding identity was reused")
}
deletesBefore := len(agent.commandsOfType("DeleteService"))
for _, body := range []string{
fmt.Sprintf(`{"bindingId":%q,"reservationId":%q,"resourceKey":"role-resource"}`, old.BindingID, old.ReservationID),
fmt.Sprintf(`{"reservationId":%q,"resourceKey":"role-resource"}`, old.ReservationID),
} {
if code := roleRuntimeRequest(t, agent.h, body, true); code != 0 {
t.Fatal("obsolete release should acknowledge completion")
}
}
// Also cover an old lookup snapshot waiting behind reservation renewal.
if err := agent.h.releasePeerShareRuntime(old); err != nil {
t.Fatal(err)
}
after, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil || after.Status != 1 || after.BindingID != fresh.BindingID {
t.Fatalf("old cleanup released new generation: %+v %v", after, err)
}
if len(agent.commandsOfType("DeleteService")) != deletesBefore {
t.Fatal("old cleanup sent deletion for new generation")
}
}
@@ -1,190 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"go-backend/internal/store/repo"
)
// Serialize desired-state changes with reconnect reconciliation. In particular,
// a stale reconnect snapshot must never revive an acknowledged release.
var peerRoleRuntimeMu sync.Mutex
// applyPeerShareRoleRuntime requires peerRoleRuntimeMu. Persist the validated
// desired configuration before creating resources, so failed or interrupted
// commands remain recoverable rather than producing untracked listeners.
func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) error {
if runtime.ReleasePending != 0 || runtime.Status != 1 {
return errors.New("runtime is being released")
}
if runtime.Role != "middle" && runtime.Role != "exit" {
return errors.New("invalid runtime role")
}
node, err := h.getNodeRecord(runtime.NodeID)
if err != nil {
return err
}
var targets []federationRuntimeTarget
if strings.TrimSpace(runtime.Target) != "" {
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
return err
}
}
var chain map[string]interface{}
if runtime.Role == "middle" {
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
if err != nil {
return err
}
}
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName)
runtime.Applied = 0
runtime.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
return err
}
if h.wsServer == nil {
return errors.New("node command transport unavailable")
}
if chain != nil {
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateChains", updateChainPayload(runtime.ChainName, chain), false, false); err != nil {
return err
}
}
// UpdateService and UpdateChains are upserts, including on an empty agent.
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateService", []map[string]interface{}{service}, false, false); err != nil {
return err
}
runtime.Applied = 1
runtime.UpdatedTime = time.Now().UnixMilli()
return h.repo.UpdatePeerShareRuntime(runtime)
}
func (h *Handler) releasePeerShareRuntime(runtime *repo.PeerShareRuntime) error {
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
if runtime == nil {
return nil
}
current, err := h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil {
return err
}
if current != nil && (current.ReservationID != runtime.ReservationID || current.BindingID != runtime.BindingID) {
// A completed reservation may have been reused while this release
// waited for the mutation lock. Never release the new generation.
return nil
}
return h.releasePeerShareRuntimeLocked(current)
}
func (h *Handler) releasePeerShareRuntimeLocked(runtime *repo.PeerShareRuntime) error {
if runtime == nil || runtime.Status == 0 {
return nil
}
if err := h.repo.SetPeerShareRuntimeReleasePending(runtime.ID); err != nil {
return err
}
runtime.ReleasePending = 1
if runtime.Role == "forward" {
if _, _, scoped := parsePeerShareServiceName(runtime.ServiceName); scoped {
if err := h.releasePeerShareForwardRuntimeResources(runtime); err != nil {
return err
}
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
}
// Older agents used unscoped names. Do not delete a name shared by
// another reservation or by a local forward during migration.
owners, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(runtime.NodeID, runtime.ServiceName)
if err != nil {
return err
}
for _, owner := range owners {
if owner.ShareID != runtime.ShareID {
return fmt.Errorf("legacy runtime %q has ambiguous ownership", runtime.ServiceName)
}
}
if forwardID, _, _, ok := parseFlowServiceIDs(normalizeForwardRuntimeServiceName(runtime.ServiceName)); ok {
local, err := h.repo.GetForwardRecord(forwardID)
if err != nil {
return err
}
if local != nil {
return fmt.Errorf("legacy runtime %q collides with a local forward", runtime.ServiceName)
}
}
}
// ServiceName is saved before apply; Applied=0 can therefore mean an
// unacknowledged command, and must not bypass deletion.
if strings.TrimSpace(runtime.ServiceName) != "" || strings.TrimSpace(runtime.ChainName) != "" {
if h.wsServer == nil {
return errors.New("node command transport unavailable")
}
if strings.TrimSpace(runtime.ServiceName) != "" {
names := []string{runtime.ServiceName}
if runtime.Role == "forward" {
names = append(names, runtime.ServiceName+"_tcp", runtime.ServiceName+"_udp")
}
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
return err
}
}
if strings.TrimSpace(runtime.ChainName) != "" {
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true); err != nil {
return err
}
}
}
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
}
func (h *Handler) reconcilePeerShareRoleRuntimesOnNode(nodeID int64) error {
return h.reconcilePeerShareRoleRuntimes(nodeID, false)
}
// Maintenance retries only unfinished desired state. Replaying acknowledged
// services while an unrelated operation is failing can interrupt live traffic.
func (h *Handler) retryPendingPeerShareRoleRuntimesOnNode(nodeID int64) error {
return h.reconcilePeerShareRoleRuntimes(nodeID, true)
}
func (h *Handler) reconcilePeerShareRoleRuntimes(nodeID int64, pendingOnly bool) error {
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
if err != nil {
return err
}
var reconcileErr error
for i := range runtimes {
runtime := &runtimes[i]
if pendingOnly && runtime.Applied == 1 && runtime.ReleasePending == 0 {
continue
}
if runtime.ReleasePending != 0 {
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
continue
}
if runtime.Role != "middle" && runtime.Role != "exit" {
continue
}
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil {
reconcileErr = errors.Join(reconcileErr, err)
continue
}
if share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
continue
}
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("shared runtime %d: %w", runtime.ID, err))
}
}
return reconcileErr
}
@@ -1,356 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
"gorm.io/gorm"
)
func roleRuntimeFixture(t *testing.T, agent *cleanupAgent, role string) *repo.PeerShareRuntime {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: "role-recovery", NodeID: 1, Token: "role-recovery-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
if err := agent.h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
runtime := &repo.PeerShareRuntime{ShareID: share.ID, NodeID: 1, ReservationID: "role-reservation", ResourceKey: "role-resource", BindingID: "333", Role: role, ServiceName: "fed_svc_333", Protocol: "tls", Strategy: "round", Port: 31000, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}
if role == "middle" {
runtime.ChainName = "fed_chain_333"
runtime.Target = `[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
}
if err := agent.h.repo.CreatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
// Runtime-generated names use the provider's globally unique runtime ID.
runtime.BindingID = fmt.Sprint(runtime.ID)
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
if role == "middle" {
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
}
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
return runtime
}
func roleRuntimeRequest(t *testing.T, h *Handler, body string, release bool) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/runtime", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer role-recovery-token")
res := httptest.NewRecorder()
if release {
h.federationRuntimeReleaseRole(res, req)
} else {
h.federationRuntimeApplyRole(res, req)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
t.Logf("runtime response: %s", res.Body.String())
return result.Code
}
func TestPeerShareAppliedRuntimeRepairsEmptyAgent(t *testing.T) {
for _, role := range []string{"exit", "middle"} {
t.Run(role, func(t *testing.T) {
agent := newCleanupAgent(t)
roleRuntimeFixture(t, agent, role)
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect reconciliation failed")
}
if got := len(agent.commandsOfType("UpdateService")); got != 1 {
t.Fatalf("reconnect did not restore service: %d", got)
}
targets := ""
if role == "middle" {
targets = `,"targets":[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
}
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"`+role+`","protocol":"tls"`+targets+`}`, false); code != 0 {
t.Fatalf("apply failed: %d", code)
}
if got := len(agent.commandsOfType("UpdateService")); got != 2 {
t.Fatalf("Applied=1 skipped service repair: %d", got)
}
if role == "middle" {
if got := len(agent.commandsOfType("UpdateChains")); got != 2 {
t.Fatalf("missing middle chains: %d", got)
}
agent.mu.Lock()
defer agent.mu.Unlock()
chainSeen := false
for _, cmd := range agent.commands {
if cmd.Type == "UpdateChains" {
chainSeen = true
}
if cmd.Type == "UpdateService" && !chainSeen {
t.Fatal("service started before chain")
}
}
}
})
}
}
func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
t.Fatal("offline release reported success")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 1 || stored.ReleasePending != 1 {
t.Fatalf("pending release lost: %+v err=%v", stored, err)
}
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || !occupied {
t.Fatalf("pending release freed occupied port: %t %v", occupied, err)
}
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32000}]}`, false); code == 0 {
t.Fatal("pending release was revived by apply")
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect cleanup failed")
}
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 || stored.Applied != 0 || stored.ReleasePending != 0 {
t.Fatalf("release not completed: %+v %v", stored, err)
}
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("DeleteChains")) != 1 {
t.Fatal("reconnect did not delete both service and chain")
}
if len(agent.commandsOfType("UpdateService")) != 0 || len(agent.commandsOfType("UpdateChains")) != 0 {
t.Fatal("reconnect revived pending release")
}
occupied, err = agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || occupied {
t.Fatalf("acknowledged release still occupies port: %t %v", occupied, err)
}
}
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
callback := "test:fail-role-desired-write"
if err := agent.h.repo.DB().Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("desired-state write failed"))
}
}); err != nil {
t.Fatal(err)
}
// This database is test-local; keep the callback registered through the
// websocket teardown so callback mutation cannot race node status writes.
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
t.Fatal("failed ownership persistence reported success")
}
if len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("service started before ownership persisted")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Applied != 1 {
t.Fatalf("old runtime changed on failed persistence: %+v %v", stored, err)
}
}
func TestPeerShareApplyInvalidMiddleKeepsDesiredConfig(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[]}`, false); code == 0 {
t.Fatal("invalid middle targets accepted")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Target != runtime.Target || stored.Applied != 1 {
t.Fatalf("invalid update changed desired runtime: %+v %v", stored, err)
}
if len(agent.commandsOfType("UpdateService"))+len(agent.commandsOfType("UpdateChains")) != 0 {
t.Fatal("invalid config sent to agent")
}
}
func TestPeerShareUnacknowledgedApplyRecoversOrReleases(t *testing.T) {
for _, release := range []bool{false, true} {
t.Run(fmt.Sprintf("release=%t", release), func(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32001,"protocol":"tls"}]}`, false); code == 0 {
t.Fatal("offline apply reported success")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Applied != 0 || !strings.Contains(stored.Target, "32001") || stored.ServiceName == "" {
t.Fatalf("unacknowledged desired state lost: %+v %v", stored, err)
}
if release {
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
t.Fatal("unacknowledged listener was freed offline")
}
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect failed")
}
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil {
t.Fatal(err)
}
if release {
if stored.Status != 0 || len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatalf("unacknowledged apply revived after release: %+v", stored)
}
} else {
if stored.Status != 1 || stored.Applied != 1 || len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatalf("unacknowledged desired state not recovered: %+v", stored)
}
}
})
}
}
func TestPeerShareDeleteOfflineRetainsDisabledShare(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
req := httptest.NewRequest(http.MethodPost, "/share/delete", strings.NewReader(fmt.Sprintf(`{"id":%d}`, runtime.ShareID)))
res := httptest.NewRecorder()
agent.h.federationShareDelete(res, req)
var result struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code == 0 {
t.Fatal("offline share delete reported success")
}
share, err := agent.h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil || share.IsActive != 0 {
t.Fatalf("cleanup retry record lost or still accepts allocations: %+v %v", share, err)
}
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || !occupied {
t.Fatal("offline share deletion released its port")
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("disabled share cleanup did not retry")
}
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("disabled share was recreated")
}
}
func TestPeerShareLegacyReleaseWithoutLocalCollisionDeletesFamily(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "forward")
runtime.ServiceName = "70_1_0"
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
if err := agent.h.releasePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
commands := agent.commandsOfType("DeleteService")
if len(commands) != 1 {
t.Fatalf("expected one family cleanup, got %d", len(commands))
}
var payload struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(commands[0].Data, &payload); err != nil {
t.Fatal(err)
}
got := strings.Join(payload.Services, ",")
if got != "70_1_0,70_1_0_tcp,70_1_0_udp" {
t.Fatalf("incomplete legacy cleanup: %s", got)
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 || stored.ReleasePending != 0 {
t.Fatalf("legacy release incomplete: %+v %v", stored, err)
}
}
func TestPeerSharePendingRoleRetryDoesNotReplaySuccessfulServices(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("maintenance replayed an acknowledged service")
}
runtime.Applied = 0
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("maintenance did not retry unfinished service")
}
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("maintenance repeated a successful retry")
}
}
func TestPeerShareResourceFailureDoesNotBlockRoleOrLocalRecovery(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: runtime.ShareID, NodeID: 1, Kind: "chain", OriginalName: "broken", RuntimeName: "broken", Config: "invalid-json", DesiredState: "active", Applied: 0, UpdatedTime: time.Now().UnixMilli()}}); err != nil {
t.Fatal(err)
}
var localQueries atomic.Int32
if err := agent.h.repo.DB().Callback().Query().Before("gorm:query").Register("test:observe-local-recovery", func(tx *gorm.DB) {
if tx.Statement.Table == "chain_tunnel" || tx.Statement.Table == "forward_port" {
localQueries.Add(1)
}
}); err != nil {
t.Fatal(err)
}
if agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("invalid shared resource was reported recovered")
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("resource failure blocked independent role service recovery")
}
if localQueries.Load() < 2 {
t.Fatal("shared failure blocked independent local recovery")
}
// A shared failure is owned by pending maintenance, without full-redeploy
// timers that would periodically restart healthy listeners.
agent.h.onNodeOnline(1)
agent.h.upgradeMu.Lock()
_, fullQueued := agent.h.nodeOnlineRedeployQueued[1]
_, localQueued := agent.h.nodeLocalRuntimeRetryQueued[1]
agent.h.upgradeMu.Unlock()
if fullQueued || localQueued {
t.Fatal("shared-only failure queued a full or local redeploy")
}
before := len(agent.commandsOfType("UpdateService"))
agent.h.retryNodeLocalRuntime(1)
if len(agent.commandsOfType("UpdateService")) != before {
t.Fatal("local retry replayed shared listeners")
}
}
+7 -93
View File
@@ -398,80 +398,15 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
}
func (h *Handler) onNodeOnline(nodeID int64) {
if h == nil || h.repo == nil || h.wsServer == nil {
return
}
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
return // The next connection will resume any pending reconciliation.
}
if !h.startNodeOnlineRedeploy(nodeID, time.Now()) {
return
}
defer h.finishNodeOnlineRedeploy(nodeID)
// A fresh agent needs a full restore. Shared failures remain persisted and
// are retried by maintenance without restarting acknowledged services.
h.reconcileSharedNodeRuntime(nodeID)
if !h.redeployLocalNodeRuntime(nodeID) {
h.scheduleNodeLocalRuntimeRetry(nodeID)
} else {
h.clearNodeLocalRuntimeRetry(nodeID)
}
}
// Local retries are separate from reconnect reconciliation: a failed local
// forward must not cause every shared listener to be replayed every 30 seconds.
func (h *Handler) scheduleNodeLocalRuntimeRetry(nodeID int64) {
h.upgradeMu.Lock()
defer h.upgradeMu.Unlock()
if h.nodeLocalRuntimeRetryQueued == nil {
h.nodeLocalRuntimeRetryQueued = make(map[int64]struct{})
}
if _, queued := h.nodeLocalRuntimeRetryQueued[nodeID]; queued {
return
}
h.nodeLocalRuntimeRetryQueued[nodeID] = struct{}{}
time.AfterFunc(nodeOnlineRedeployCooldown, func() {
h.upgradeMu.Lock()
_, queued := h.nodeLocalRuntimeRetryQueued[nodeID]
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
h.upgradeMu.Unlock()
if !queued {
return
}
h.retryNodeLocalRuntime(nodeID)
})
}
func (h *Handler) clearNodeLocalRuntimeRetry(nodeID int64) {
h.upgradeMu.Lock()
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
h.upgradeMu.Unlock()
}
func (h *Handler) retryNodeLocalRuntime(nodeID int64) {
if h == nil || h.repo == nil || h.wsServer == nil {
return
}
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
return
}
h.upgradeMu.Lock()
if _, inFlight := h.nodeOnlineRedeploying[nodeID]; inFlight {
h.upgradeMu.Unlock()
h.scheduleNodeLocalRuntimeRetry(nodeID)
return
}
if h.nodeOnlineRedeploying == nil {
h.nodeOnlineRedeploying = make(map[int64]struct{})
}
h.nodeOnlineRedeploying[nodeID] = struct{}{}
h.upgradeMu.Unlock()
defer h.finishNodeOnlineRedeploy(nodeID)
if !h.redeployLocalNodeRuntime(nodeID) {
h.scheduleNodeLocalRuntimeRetry(nodeID)
} else {
h.clearNodeLocalRuntimeRetry(nodeID)
// Reconcile node runtime on the first reconnect, but suppress rapid flapping
// so websocket churn does not trigger repeated full redeploy storms.
if !h.redeployNodeRuntimeAfterUpgrade(nodeID) {
h.markNodePendingUpgradeRedeploy(nodeID)
}
}
@@ -568,25 +503,6 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
}
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
localOK := h.redeployLocalNodeRuntime(nodeID)
return sharedOK && localOK
}
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
succeeded := true
if err := h.reconcilePeerShareResourcesOnNode(nodeID); err != nil {
fmt.Printf("reconnect shared resource reconciliation failed on node %d: %v\n", nodeID, err)
succeeded = false
}
if err := h.reconcilePeerShareRoleRuntimesOnNode(nodeID); err != nil {
fmt.Printf("reconnect shared role reconciliation failed on node %d: %v\n", nodeID, err)
succeeded = false
}
return succeeded
}
func (h *Handler) redeployLocalNodeRuntime(nodeID int64) bool {
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
@@ -651,7 +567,6 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
return true
}
permanentFailure := false
const maxRetries = 3
baseDelay := time.Second
@@ -665,8 +580,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
delete(tunnelFailed, tunnelID)
fmt.Printf("post-upgrade redeploy retry: tunnel %d succeeded on node %d (attempt %d)\n", tunnelID, nodeID, attempt)
} else if !isRetryableError(err) {
permanentFailure = true
delete(tunnelFailed, tunnelID) // Preserve failure while avoiding immediate retries.
delete(tunnelFailed, tunnelID) // Non-retryable, don't retry again
} else {
fmt.Printf("post-upgrade redeploy retry: tunnel %d still failing on node %d (attempt %d): %v\n", tunnelID, nodeID, attempt, err)
}
@@ -682,7 +596,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
if err := h.syncForwardServices(ff.forward, "UpdateService", true); err == nil {
fmt.Printf("post-upgrade redeploy retry: forward %d succeeded on node %d (attempt %d)\n", ff.id, nodeID, attempt)
} else if !isRetryableError(err) {
permanentFailure = true // Keep reconciliation pending for a later reconnect.
// Non-retryable, drop it
} else {
stillFailed = append(stillFailed, ff)
fmt.Printf("post-upgrade redeploy retry: forward %d still failing on node %d (attempt %d): %v\n", ff.id, nodeID, attempt, err)
@@ -692,7 +606,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
fmt.Printf("post-upgrade redeploy retry: all items recovered on node %d\n", nodeID)
return !permanentFailure
return true
}
}
@@ -12,7 +12,7 @@ func TestStartNodeOnlineRedeploySkipsRecentReconnects(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Now()
now := time.Unix(1_777_176_720, 0)
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -34,7 +34,7 @@ func TestStartNodeOnlineRedeployAllowsPendingUpgradeDuringCooldown(t *testing.T)
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Now()
now := time.Unix(1_777_176_720, 0)
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -57,7 +57,7 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Now()
now := time.Unix(1_777_176_720, 0)
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -67,10 +67,7 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
t.Fatalf("expected cooldown reconnect to skip immediate redeploy")
}
h.upgradeMu.Lock()
_, queued := h.nodeOnlineRedeployQueued[54]
h.upgradeMu.Unlock()
if !queued {
if _, queued := h.nodeOnlineRedeployQueued[54]; !queued {
t.Fatalf("expected cooldown reconnect to queue a follow-up redeploy")
}
}
@@ -82,7 +79,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Now()
now := time.Unix(1_777_176_720, 0)
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -99,7 +96,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
}
func TestNextNodeOnlineRedeployFireAtDefersExpiredInFlightReconnect(t *testing.T) {
now := time.Now()
now := time.Unix(1_777_176_720, 0)
last := now.Add(-nodeOnlineRedeployCooldown - 5*time.Second)
fireAt, start := nextNodeOnlineRedeployFireAt(last, now, false, true)
@@ -1,527 +0,0 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/ws"
)
func TestUserUpdateDisablesPermissionsAndRunningForwards(t *testing.T) {
for _, password := range []string{"", "replacement-password"} {
t.Run(fmt.Sprintf("password_changed=%t", password != ""), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
for _, userID := range []int64{2, 3} {
if err := a.h.repo.DB().Create(&model.User{ID: userID, User: fmt.Sprintf("user%d", userID), Pwd: "pwd", RoleID: 1, Status: 1, ExpTime: now + 86400000, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 1, Name: "test", Type: 1, Protocol: "tls", Status: 1, Flow: 1}).Error; err != nil {
t.Fatal(err)
}
for _, permission := range []model.UserTunnel{
{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
{ID: 11, UserID: 2, TunnelID: 2, Status: 0, Flow: 100, Num: 10},
{ID: 12, UserID: 3, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
} {
if err := a.h.repo.DB().Create(&permission).Error; err != nil {
t.Fatal(err)
}
}
for _, forward := range []model.Forward{
{ID: 20, UserID: 2, UserName: "user2", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
{ID: 21, UserID: 2, UserName: "user2", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 0},
{ID: 22, UserID: 3, UserName: "user3", Name: "other-user", TunnelID: 1, RemoteAddr: "127.0.0.1:82", Status: 1},
} {
if err := a.h.repo.DB().Create(&forward).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: 1, Port: 31000 + int(forward.ID)}).Error; err != nil {
t.Fatal(err)
}
}
update := func(status int) {
t.Helper()
payload, err := json.Marshal(map[string]interface{}{"id": 2, "user": "user2", "status": status, "pwd": password, "flow": 100, "num": 10, "expTime": now + 86400000})
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
a.h.userUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(payload)))
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Fatalf("update user: %d %s", result.Code, result.Msg)
}
}
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 1 {
t.Errorf("ordinary account update disabled an enabled permission: %d", got)
}
if len(a.commandsOfType("PauseService")) != 0 {
t.Error("ordinary account update paused services")
}
update(0)
for _, check := range []struct {
table string
id int64
want int
}{{"user", 2, 0}, {"user_tunnel", 10, 0}, {"user_tunnel", 11, 0}, {"user_tunnel", 12, 1}, {"forward", 20, 0}, {"forward", 21, 0}, {"forward", 22, 1}} {
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM "+check.table+" WHERE id = ?", check.id); got != check.want {
t.Errorf("%s %d status = %d, want %d", check.table, check.id, got, check.want)
}
}
commands := a.commandsOfType("PauseService")
if len(commands) == 0 {
t.Error("running forward never received PauseService")
}
for _, command := range commands {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
if !strings.HasPrefix(name, "20_2_") {
t.Errorf("paused unrelated service %q", name)
}
}
}
update(0)
if len(a.commandsOfType("PauseService")) != len(commands) {
t.Error("repeated disable replayed pauses for already paused forwards")
}
// Account edits and re-enabling must not grant disabled permissions or
// resume forwards that an administrator previously paused.
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("account re-enable unexpectedly granted a disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 11"); got != 0 {
t.Errorf("re-enabled independently disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("resumed previously paused forward: %d", got)
}
if len(a.commandsOfType("ResumeService")) != 0 {
t.Error("account re-enable unexpectedly resumed services")
}
})
}
}
func TestUserDisableMultiEntryNeverRecreatesMissingServices(t *testing.T) {
for _, secondNodeState := range []string{"missing", "offline", "timeout"} {
t.Run(secondNodeState, func(t *testing.T) {
a := newCleanupAgent(t)
var remoteMu sync.Mutex
remoteServices := make(map[string]bool)
var remoteCommands []string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
remoteMu.Lock()
defer remoteMu.Unlock()
remoteCommands = append(remoteCommands, command.Type)
switch command.Type {
case "UpdateService", "AddService":
var services []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(command.Data, &services); err != nil {
t.Error(err)
return
}
for _, service := range services {
remoteServices[service.Name] = true
}
case "PauseService":
if secondNodeState != "missing" {
message := "节点不在线"
if secondNodeState == "timeout" {
message = "等待节点响应超时"
}
response.WriteJSON(w, response.ErrDefault(message))
return
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Error(err)
return
}
for _, name := range data.Services {
if _, exists := remoteServices[name]; !exists {
response.WriteJSON(w, response.OK(map[string]interface{}{"success": false, "message": "service not found"}))
return
}
remoteServices[name] = false
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("multi-entry-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Node{ID: 2, Name: "second-entry", Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: "test-token", Valid: true}},
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 1, Status: 1, Protocol: "tls", Flow: 1},
&model.ChainTunnel{TunnelID: 1, NodeID: 1, ChainType: "1"},
&model.ChainTunnel{TunnelID: 1, NodeID: 2, ChainType: "1"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "multi-entry-disable", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
&model.ForwardPort{ForwardID: 20, NodeID: 2, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "multi-entry-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
wantStatus := 1
if secondNodeState == "missing" {
wantStatus = 0
if out.Code != 0 {
t.Errorf("missing service should count as stopped: %s", out.Msg)
}
} else if out.Code == 0 {
t.Error("unconfirmed second entry pause returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != wantStatus {
t.Errorf("forward status = %d, want %d", got, wantStatus)
}
a.mu.Lock()
commands := append([]cleanupAgentCommand(nil), a.commands...)
a.mu.Unlock()
firstEntryRunning := true
for _, command := range commands {
switch command.Type {
case "PauseService":
firstEntryRunning = false
case "UpdateService", "AddService", "ResumeService":
firstEntryRunning = true
t.Errorf("disable sent %s to already paused entry A", command.Type)
}
}
if firstEntryRunning {
t.Error("entry A is running after disable")
}
remoteMu.Lock()
defer remoteMu.Unlock()
if len(remoteCommands) == 0 {
t.Error("entry B was never checked")
}
for _, command := range remoteCommands {
if command != "PauseService" {
t.Errorf("disable sent %s to entry B", command)
}
}
for name, running := range remoteServices {
if running {
t.Errorf("entry B service %s is still running", name)
}
}
})
}
}
func TestUserDisableContinuesAcrossNodesAndForwards(t *testing.T) {
for _, failure := range []string{"节点不在线", "等待节点响应超时", "pause rejected"} {
for _, failedNodeFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/failed_node_first=%t", failure, failedNodeFirst), func(t *testing.T) {
a := newCleanupAgent(t)
var mu sync.Mutex
recovered := false
attempts := make(map[string]int)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
if command.Type != "PauseService" {
t.Errorf("disable sent %s", command.Type)
}
mu.Lock()
attempts[r.Header.Get("Authorization")]++
isRecovered := recovered
mu.Unlock()
if !isRecovered {
response.WriteJSON(w, response.ErrDefault(failure))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("continue-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 2, Status: 1, Flow: 1},
&model.Node{ID: 4, Name: "offline-middle", Secret: "test", ServerIP: "127.0.0.1", Status: 0},
&model.ChainTunnel{TunnelID: 1, NodeID: 4, ChainType: "2"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "continue-disable", Name: "mixed-nodes", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.Forward{ID: 21, UserID: userID, UserName: "continue-disable", Name: "healthy-forward", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 1},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
for _, nodeID := range []int64{2, 3} {
node := model.Node{ID: nodeID, Name: fmt.Sprintf("failed-node-%d", nodeID), Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: fmt.Sprintf("test-node-%d", nodeID), Valid: true}}
if err := a.h.repo.DB().Create(&node).Error; err != nil {
t.Fatal(err)
}
}
nodeOrder := []int64{1, 2, 3}
if failedNodeFirst {
nodeOrder = []int64{2, 1, 3}
}
for _, nodeID := range nodeOrder {
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 20, NodeID: nodeID, Port: 31020}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ChainTunnel{TunnelID: 1, NodeID: nodeID, ChainType: "1"}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 21, NodeID: 1, Port: 31021}).Error; err != nil {
t.Fatal(err)
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "continue-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
out := update()
if out.Code == 0 {
t.Error("partial node failure reported complete success")
}
state, ok := out.Data.(map[string]interface{})
if !ok || state["userDisabled"] != true {
t.Errorf("partial response did not tell the UI the account is disabled: %#v", out.Data)
}
for _, nodeID := range []int64{2, 3} {
if !strings.Contains(out.Msg, fmt.Sprintf("failed-node-%d", nodeID)) || !strings.Contains(out.Msg, fmt.Sprintf("ID %d", nodeID)) {
t.Errorf("failure response did not identify node %d: %s", nodeID, out.Msg)
}
mu.Lock()
count := attempts[fmt.Sprintf("Bearer test-node-%d", nodeID)]
mu.Unlock()
if count == 0 {
t.Errorf("failure on another node prevented node %d from being attempted", nodeID)
}
}
paused := make(map[string]bool)
for _, command := range a.commandsOfType("PauseService") {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
paused[strings.Split(name, "_")[0]] = true
}
}
for _, id := range []string{"20", "21"} {
if !paused[id] {
t.Errorf("healthy node never paused forward %s", id)
}
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed forward status = %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("healthy later forward was not paused: %d", got)
}
mu.Lock()
recovered = true
mu.Unlock()
if retry := update(); retry.Code != 0 {
t.Errorf("retry after recovery failed: %s", retry.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not confirm all nodes: %d", got)
}
for _, kind := range []string{"UpdateService", "AddService", "ResumeService"} {
if len(a.commandsOfType(kind)) != 0 {
t.Errorf("disable sent %s", kind)
}
}
})
}
}
}
func TestUserDisableRemovesNftablesRuleOnlyAfterConfirmedApply(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
t.Cleanup(func() { _ = h.repo.Close() })
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "disable-nft", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
now := time.Now().UnixMilli()
userID, err := h.repo.CreateUser("disable-nft", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Model(&model.Forward{}).Where("id = ?", forward.ID).Update("user_id", userID).Error; err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Create(&model.UserTunnel{UserID: userID, TunnelID: tunnelID, Status: 1, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
otherForwardID := forward.ID + 100
for _, value := range []interface{}{
&model.Forward{ID: otherForwardID, UserID: 1, UserName: "admin", Name: "other-user", TunnelID: tunnelID, RemoteAddr: "203.0.113.9:8081", Status: 1},
&model.ForwardPort{ForwardID: otherForwardID, NodeID: fixture.nodeID, Port: 20001},
} {
if err := h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
manager := &fakeNftablesManager{reconcileErr: errors.New("ssh unavailable")}
h.nftablesManager = manager
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-nft", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("failed SSH apply returned success")
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 1 {
t.Errorf("unconfirmed nftables pause marked completed: %d", got)
}
if manager.reconcileCount() != 1 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Fatalf("did not attempt to remove disabled user's nftables rule: %+v", manager.lastPlan)
}
manager.reconcileErr = nil
if out := update(); out.Code != 0 {
t.Fatalf("retry nftables disable: %s", out.Msg)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 0 {
t.Errorf("confirmed nftables pause was not stored: %d", got)
}
if manager.reconcileCount() != 2 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Errorf("retry did not apply a plan without disabled forward: %+v", manager.lastPlan)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", otherForwardID); got != 1 {
t.Errorf("disabled another user's nftables forward: %d", got)
}
}
func TestUserDisableReportsPauseFailureAndCanRetry(t *testing.T) {
for _, offline := range []bool{false, true} {
t.Run(fmt.Sprintf("offline=%t", offline), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("disable-retry", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "test", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "disable-retry", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
onlineServer := a.h.wsServer
if offline {
a.h.wsServer = ws.NewServer(a.h.repo, "test-jwt-secret")
} else {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
response.WriteJSON(w, response.ErrDefault("pause rejected"))
}))
defer ts.Close()
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Updates(map[string]interface{}{"is_remote": 1, "remote_url": ts.URL, "remote_token": "test-token"}).Error; err != nil {
t.Fatal(err)
}
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-retry", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("pause failure returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user WHERE id = ?", userID); got != 0 {
t.Errorf("failed pause restored account access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("failed pause restored tunnel access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed pause marked completed, status = %d", got)
}
a.h.wsServer = onlineServer
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Update("is_remote", 0).Error; err != nil {
t.Fatal(err)
}
if out := update(); out.Code != 0 {
t.Fatalf("retry failed: %s", out.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not pause forward: %d", got)
}
if len(a.commandsOfType("PauseService")) == 0 {
t.Error("retry never sent PauseService")
}
})
}
}
@@ -116,8 +116,6 @@ func shouldSkip(path string) bool {
return false
case path == "/api/v1/user/login":
return true
case path == "/api/v1/user/passkey/status" || path == "/api/v1/user/passkey/login/begin" || path == "/api/v1/user/passkey/login/finish":
return true
case path == "/api/v1/public/config/get":
return true
case path == "/api/v1/federation/connect":
@@ -1,15 +0,0 @@
package model
// FederationPendingRelease keeps rollback work after a failed remote release.
// It is independent of tunnels, which may never have committed or be deleted.
type FederationPendingRelease struct {
ID string `gorm:"primaryKey;size:64"`
RemoteURL string `gorm:"not null"`
RemoteToken string `gorm:"not null"`
BindingID string
ReservationID string
ResourceKey string
CreatedTime int64
}
func (FederationPendingRelease) TableName() string { return "federation_pending_release" }
+17 -38
View File
@@ -354,24 +354,23 @@ type PeerShare struct {
func (PeerShare) TableName() string { return "peer_share" }
type PeerShareRuntime struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
Role string `gorm:"type:text;not null;default:''"`
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
Protocol string `gorm:"type:text;not null;default:'tls'"`
Strategy string `gorm:"type:text;not null;default:'round'"`
Port int `gorm:"not null;default:0"`
Target string `gorm:"type:text;not null;default:''"`
Applied int `gorm:"not null;default:0"`
ReleasePending int `gorm:"column:release_pending;not null;default:0"`
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
Role string `gorm:"type:text;not null;default:''"`
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
Protocol string `gorm:"type:text;not null;default:'tls'"`
Strategy string `gorm:"type:text;not null;default:'round'"`
Port int `gorm:"not null;default:0"`
Target string `gorm:"type:text;not null;default:''"`
Applied int `gorm:"not null;default:0"`
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
@@ -817,23 +816,3 @@ type TunnelQuality struct {
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
// PeerShareResource is the durable desired state for a namespaced peer command.
// Rows are retained as tombstones until deletion has been acknowledged.
type PeerShareResource struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;uniqueIndex:idx_peer_share_resource_key"`
NodeID int64 `gorm:"column:node_id;not null;index"`
Kind string `gorm:"type:text;not null;uniqueIndex:idx_peer_share_resource_key"`
OriginalName string `gorm:"column:original_name;type:text;not null;uniqueIndex:idx_peer_share_resource_key"`
RuntimeName string `gorm:"column:runtime_name;type:text;not null;index"`
LegacyNames string `gorm:"column:legacy_names;type:text;not null;default:''"`
LegacyServiceBase string `gorm:"column:legacy_service_base;type:text;not null;default:''"`
ReleaseLegacyFamily bool `gorm:"column:release_legacy_family;not null;default:false"`
Config string `gorm:"type:text;not null;default:''"`
DesiredState string `gorm:"column:desired_state;type:text;not null;default:'active'"`
Applied int `gorm:"not null;default:0"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (PeerShareResource) TableName() string { return "peer_share_resource" }
@@ -1,13 +0,0 @@
package model
// Passkey stores a WebAuthn credential for exactly one panel user.
type Passkey struct {
ID string `gorm:"primaryKey;type:varchar(512)"`
UserID int64 `gorm:"column:user_id;not null;index"`
Name string `gorm:"type:varchar(100);not null"`
CredentialJSON string `gorm:"column:credential_json;type:text;not null"`
CreatedAt int64 `gorm:"column:created_at;not null"`
LastUsedAt int64 `gorm:"column:last_used_at;not null;default:0"`
}
func (Passkey) TableName() string { return "passkey" }
@@ -1,55 +0,0 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) SavePeerShareResources(items []PeerShareResource) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if len(items) == 0 {
return nil
}
return r.db.Transaction(func(tx *gorm.DB) error {
for i := range items {
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "share_id"}, {Name: "kind"}, {Name: "original_name"}}, DoUpdates: clause.AssignmentColumns([]string{"node_id", "runtime_name", "legacy_names", "legacy_service_base", "release_legacy_family", "config", "desired_state", "applied", "updated_time"})}).Create(&items[i]).Error; err != nil {
return err
}
}
return nil
})
}
func (r *Repository) GetPeerShareResource(shareID int64, kind, originalName string) (*PeerShareResource, error) {
var item model.PeerShareResource
err := r.db.Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, originalName).First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &item, err
}
func (r *Repository) ListPeerShareResourcesByNode(nodeID int64) ([]PeerShareResource, error) {
var items []PeerShareResource
err := r.db.Where("node_id = ?", nodeID).Order("id").Find(&items).Error
return items, err
}
func (r *Repository) MarkPeerShareResourceApplied(shareID int64, kind, name string) error {
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, name).Update("applied", 1).Error
}
func (r *Repository) ClearPeerShareResourceLegacyNames(shareID int64, kind, name string) error {
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND kind = ? AND original_name = ?", shareID, kind, name).Update("legacy_names", "").Error
}
func (r *Repository) WithPeerShareResourceTransaction(fn func(*Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error { return fn(&Repository{db: tx, dbPath: r.dbPath}) })
}
func (r *Repository) ClearPeerShareResourceLegacyFamily(shareID int64, base string) error {
return r.db.Model(&model.PeerShareResource{}).Where("share_id = ? AND legacy_service_base = ?", shareID, base).Update("legacy_service_base", "").Error
}
@@ -1,32 +0,0 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"time"
)
// A pending release remains active until the agent acknowledges deletion. This
// keeps its port reserved even if the control connection is unavailable.
func (r *Repository) SetPeerShareRuntimeReleasePending(id int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ? AND status = 1", id).Updates(map[string]interface{}{"release_pending": 1, "updated_time": time.Now().UnixMilli()}).Error
}
func (r *Repository) CompletePeerShareRuntimeRelease(id int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{"status": 0, "applied": 0, "release_pending": 0, "updated_time": time.Now().UnixMilli()}).Error
}
func (r *Repository) ListActivePeerShareRuntimesByNode(nodeID int64) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("node_id = ? AND status = 1", nodeID).Order("release_pending DESC, id ASC").Find(&items).Error
return items, err
}
+24 -18
View File
@@ -43,7 +43,6 @@ type UserForwardDetail = model.UserForwardDetail
type StatisticsFlow = model.StatisticsFlow
type Node = model.Node
type PeerShare = model.PeerShare
type PeerShareResource = model.PeerShareResource
type PeerShareRuntime = model.PeerShareRuntime
type FederationTunnelBinding = model.FederationTunnelBinding
type BackupData = model.BackupData
@@ -288,7 +287,6 @@ func autoMigrateAll(db *gorm.DB) error {
models := []interface{}{
&model.User{},
&model.Passkey{},
&model.UserQuota{},
&model.Forward{},
&model.ForwardPort{},
@@ -311,9 +309,7 @@ func autoMigrateAll(db *gorm.DB) error {
&model.ViteConfig{},
&model.PeerShare{},
&model.PeerShareRuntime{},
&model.PeerShareResource{},
&model.FederationTunnelBinding{},
&model.FederationPendingRelease{},
&model.Announcement{},
&model.SchemaVersion{},
&model.NodeMetric{},
@@ -1518,12 +1514,7 @@ func (r *Repository) DeletePeerShare(id int64) error {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("share_id = ?", id).Delete(&model.PeerShareRuntime{}).Error; err != nil {
return err
}
if err := tx.Where("share_id = ?", id).Delete(&model.PeerShareResource{}).Error; err != nil {
return err
}
tx.Where("share_id = ?", id).Delete(&model.PeerShareRuntime{})
return tx.Where("id = ?", id).Delete(&model.PeerShare{}).Error
})
}
@@ -1616,8 +1607,7 @@ func (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error
return errors.New("runtime item is nil")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"reservation_id": item.ReservationID,
"binding_id": item.BindingID, "role": item.Role,
"binding_id": item.BindingID, "role": item.Role,
"chain_name": item.ChainName, "service_name": item.ServiceName,
"protocol": item.Protocol, "strategy": item.Strategy,
"port": item.Port, "target": item.Target,
@@ -1765,19 +1755,35 @@ func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(node
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByNode(nodeID int64) ([]model.PeerShareRuntime, error) {
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("node_id = ? AND status = 1 AND role = ?", nodeID, "forward").Find(&items).Error
var names []string
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
Pluck("service_name", &names).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
if names == nil {
names = make([]string, 0)
}
return items, nil
return names, nil
}
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
@@ -1,55 +0,0 @@
package repo
import (
"crypto/sha256"
"fmt"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const FederationBindingPendingRelease = 2
func (r *Repository) ListTunnelChainNodeIDsTx(tx *gorm.DB, tunnelID int64) ([]int64, error) {
var ids []int64
err := tx.Model(&model.ChainTunnel{}).Where("tunnel_id = ?", tunnelID).Pluck("node_id", &ids).Error
return ids, err
}
func (r *Repository) ListFederationTunnelBindingsForCleanup(tunnelID int64) ([]model.FederationTunnelBinding, error) {
var rows []model.FederationTunnelBinding
err := r.db.Where("tunnel_id = ? AND status IN ?", tunnelID, []int{1, FederationBindingPendingRelease}).Order("id").Find(&rows).Error
return rows, err
}
func (r *Repository) ListPendingFederationTunnelBindings() ([]model.FederationTunnelBinding, error) {
var rows []model.FederationTunnelBinding
err := r.db.Where("status = ?", FederationBindingPendingRelease).Order("id").Find(&rows).Error
return rows, err
}
func (r *Repository) MarkFederationTunnelBindingPendingRelease(id int64) error {
return r.db.Model(&model.FederationTunnelBinding{}).Where("id = ?", id).
Updates(map[string]interface{}{"status": FederationBindingPendingRelease, "updated_time": unixMilliNow()}).Error
}
func (r *Repository) DeleteFederationTunnelBinding(id int64) error {
return r.db.Where("id = ?", id).Delete(&model.FederationTunnelBinding{}).Error
}
func (r *Repository) SavePendingFederationRelease(item *model.FederationPendingRelease) error {
item.ID = fmt.Sprintf("%x", sha256.Sum256([]byte(item.RemoteURL+"\n"+item.BindingID+"\n"+item.ReservationID+"\n"+item.ResourceKey)))
item.CreatedTime = unixMilliNow()
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(item).Error
}
func (r *Repository) ListPendingFederationReleases() ([]model.FederationPendingRelease, error) {
var rows []model.FederationPendingRelease
err := r.db.Order("created_time, id").Find(&rows).Error
return rows, err
}
func (r *Repository) DeletePendingFederationRelease(id string) error {
return r.db.Where("id = ?", id).Delete(&model.FederationPendingRelease{}).Error
}
@@ -1,35 +0,0 @@
package repo
import (
"sort"
"go-backend/internal/store/model"
)
// Only retry unfinished operations. Successful desired state must be replayed
// on a real reconnect, not every maintenance tick while the agent stays online.
func (r *Repository) ListPendingPeerShareNodeIDs() ([]int64, error) {
var resourceNodes, runtimeNodes []int64
if err := r.db.Model(&model.PeerShareResource{}).Where("applied = 0").Distinct("node_id").Pluck("node_id", &resourceNodes).Error; err != nil {
return nil, err
}
if err := r.db.Model(&model.PeerShareRuntime{}).
Where("status = 1 AND (release_pending <> 0 OR (applied = 0 AND service_name <> '' AND role IN ?))", []string{"middle", "exit"}).
Distinct("node_id").Pluck("node_id", &runtimeNodes).Error; err != nil {
return nil, err
}
seen := make(map[int64]struct{})
for _, ids := range [][]int64{resourceNodes, runtimeNodes} {
for _, id := range ids {
if id > 0 {
seen[id] = struct{}{}
}
}
}
out := make([]int64, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
@@ -151,21 +151,11 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
}).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 {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Passkey{}).Error; err != nil {
return err
}
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
return err
@@ -1,51 +0,0 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
func (r *Repository) ListPasskeys(userID int64) ([]model.Passkey, error) {
var keys []model.Passkey
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&keys).Error
return keys, err
}
func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error) {
var key model.Passkey
err := r.db.Where("user_id = ? AND id = ?", userID, id).Take(&key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
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 {
return r.db.Create(key).Error
}
func (r *Repository) DeletePasskey(userID int64, id string) (bool, error) {
result := r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&model.Passkey{})
return result.RowsAffected == 1, result.Error
}
// Compare-and-swap prevents a concurrent assertion from reusing an old sign counter.
func (r *Repository) UpdatePasskeyCredential(userID int64, id, oldJSON, newJSON string, now int64) (bool, error) {
result := r.db.Model(&model.Passkey{}).
Where("user_id = ? AND id = ? AND credential_json = ?", userID, id, oldJSON).
Updates(map[string]interface{}{"credential_json": newJSON, "last_used_at": now})
return result.RowsAffected == 1, result.Error
}
@@ -743,7 +743,7 @@ func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatal("a shared-node token must not reload the entire provider node")
if out.Code != 0 {
t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg)
}
}
@@ -38,9 +38,6 @@ func TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately(t *testing
if err := repo.DB().Create(forward).Error; err != nil {
t.Fatalf("seed forward: %v", err)
}
if err := repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: node.ID, Port: 10000}).Error; err != nil {
t.Fatalf("seed forward node ownership: %v", err)
}
if err := repo.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 1, 0, ?, ?, ?, ?, 0, 0, '', ?, ?)`, bytesPerGB-100, bytesPerGB-100, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
@@ -1,316 +0,0 @@
package contract_test
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/fxamacker/cbor/v2"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
const passkeyTestOrigin = "https://panel.example.test"
func passkeyPost(t *testing.T, router http.Handler, path, token string, body interface{}) response.R {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out response.R
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return out
}
func passkeyData(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
if out.Code != 0 {
t.Fatalf("unexpected response: code=%d msg=%s", out.Code, out.Msg)
}
return out.Data.(map[string]interface{})
}
func passkeyChallenge(t *testing.T, data map[string]interface{}) string {
t.Helper()
options := data["options"].(map[string]interface{})
return options["publicKey"].(map[string]interface{})["challenge"].(string)
}
func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", "https://panel.example.test/path")
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9301, "passkey-disabled", "test-password")
status := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/status", "", map[string]string{}))
if status["enabled"] != false {
t.Fatalf("invalid origin enabled passkeys: %v", status)
}
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")
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
t.Fatalf("password login regressed: %s", result.Msg)
}
}
func TestPasskeyRegistrationAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
seedLegacyUser(t, r, 9303, "passkey-bob", "bob-password")
aliceToken, _ := auth.GenerateToken(9302, "passkey-alice", 1, "passkey-test-secret")
bobToken, _ := auth.GenerateToken(9303, "passkey-bob", 1, "passkey-test-secret")
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", "", map[string]string{"password": "alice-password"}); result.Code == 0 {
t.Fatal("unauthenticated registration was allowed")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "wrong"}); result.Code == 0 {
t.Fatal("registration did not require password re-verification")
}
begin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", bobToken, map[string]interface{}{"sessionId": begin["sessionId"], "credential": map[string]string{}}); result.Code == 0 {
t.Fatal("another user completed Alice's registration")
}
// 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"}))
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)
if err != nil {
t.Fatal(err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatal(err)
}
registerResponse := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, begin), passkeyTestOrigin)
finishBody := map[string]interface{}{"sessionId": begin["sessionId"], "credential": registerResponse, "name": "Laptop"}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code != 0 {
t.Fatalf("register: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code == 0 {
t.Fatal("registration challenge was reusable")
}
keyID := base64.RawURLEncoding.EncodeToString(credentialID)
if items := passkeyPost(t, router, "/api/v1/user/passkey/list", bobToken, map[string]string{}).Data.([]interface{}); len(items) != 0 {
t.Fatal("Alice's credential appeared in Bob's list")
}
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")
}
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")
}
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)
}
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{}))
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")
}
}
func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin string) map[string]interface{} {
t.Helper()
pub := privateKey.PublicKey
cose, err := cbor.Marshal(map[int]interface{}{1: 2, 3: -7, -1: 1, -2: pub.X.FillBytes(make([]byte, 32)), -3: pub.Y.FillBytes(make([]byte, 32))})
if err != nil {
t.Fatal(err)
}
rpHash := sha256.Sum256([]byte("panel.example.test"))
authData := append([]byte{}, rpHash[:]...)
authData = append(authData, 0x45, 0, 0, 0, 0)
authData = append(authData, make([]byte, 16)...)
length := make([]byte, 2)
binary.BigEndian.PutUint16(length, uint16(len(id)))
authData = append(authData, length...)
authData = append(authData, id...)
authData = append(authData, cose...)
attestation, err := cbor.Marshal(map[string]interface{}{"fmt": "none", "authData": authData, "attStmt": map[string]interface{}{}})
if err != nil {
t.Fatal(err)
}
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.create", "challenge": challenge, "origin": origin})
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"attestationObject": base64.RawURLEncoding.EncodeToString(attestation), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client)}}
}
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()
rpHash := sha256.Sum256([]byte(rpID))
authData := append([]byte{}, rpHash[:]...)
flags := byte(0x01)
if verified {
flags |= 0x04
}
authData = append(authData, flags, 0, 0, 0, 0)
binary.BigEndian.PutUint32(authData[33:37], count)
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.get", "challenge": challenge, "origin": origin})
clientHash := sha256.Sum256(client)
signed := append(append([]byte{}, authData...), clientHash[:]...)
hash := sha256.Sum256(signed)
signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
if err != nil {
t.Fatal(err)
}
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}}
}
@@ -58,9 +58,6 @@ func TestFlowUploadInsertsTunnelMetrics(t *testing.T) {
if err := repo.DB().Create(forward).Error; err != nil {
t.Fatalf("seed forward: %v", err)
}
if err := repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: node.ID, Port: 10000}).Error; err != nil {
t.Fatalf("seed forward node ownership: %v", err)
}
serviceName := jsonNumber(forward.ID) + "_123_0"
body, _ := json.Marshal([]map[string]interface{}{{
@@ -1,87 +0,0 @@
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
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/gost
go 1.26.10
go 1.25.0
require (
github.com/go-gost/core v0.3.1
+3 -5
View File
@@ -125,13 +125,11 @@ func main() {
distro := socket.DetectDistro()
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
p := &program{
startReporter: func() reporter {
return socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
},
}
p := &program{}
if err := svc.Run(p); err != nil {
logger.Default().Fatal(err)
}
+34 -87
View File
@@ -3,14 +3,6 @@ package main
import (
"context"
"errors"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
@@ -26,22 +18,20 @@ import (
xservice "github.com/go-gost/x/service"
"github.com/go-gost/x/socket"
"github.com/judwhite/go-svc"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type reporter interface {
Stop()
}
type program struct {
startReporter func() reporter
reporter reporter
srvApi service.Service
srvMetrics service.Service
srvProfiling *http.Server
profilingListener net.Listener
srvApi service.Service
srvMetrics service.Service
srvProfiling *http.Server
cancel context.CancelFunc
stopped bool
cancel context.CancelFunc
}
func (p *program) Init(env svc.Environment) error {
@@ -58,15 +48,7 @@ func (p *program) Init(env svc.Environment) error {
return nil
}
func (p *program) Start() (err error) {
unlock := config.LockMutation()
defer unlock()
p.stopped = false
defer func() {
if err != nil {
p.stopRuntime()
}
}()
func (p *program) Start() error {
cfg, err := parser.Parse()
if err != nil {
return err
@@ -79,28 +61,23 @@ func (p *program) Start() (err error) {
os.Exit(0)
}
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
return err
}
// Enable config persistence after initial load so runtime mutations
// (AddService, UpdateService, DeleteService, etc.) are saved to disk.
socket.EnableConfigPersist()
if err := p.run(cfg); err != nil {
return err
}
config.Set(cfg)
socket.EnableConfigPersist()
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
go p.reload(ctx, c)
// A connected panel may immediately send commands. Only expose the agent
// after initial config loading, runtime startup and persistence are ready.
if p.startReporter != nil {
p.reporter = p.startReporter()
}
go p.reload(ctx)
go func() {
select {
@@ -114,14 +91,7 @@ func (p *program) Start() (err error) {
return nil
}
func (p *program) run(cfg *config.Config) (err error) {
defer func() {
if err != nil {
// Auxiliary listeners may occupy ports required by the rollback config.
// Release all resources opened by this attempt before rebuilding it.
p.stopRuntime()
}
}()
func (p *program) run(cfg *config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
@@ -182,10 +152,6 @@ func (p *program) run(cfg *config.Config) (err error) {
if p.srvProfiling != nil {
p.srvProfiling.Close()
if p.profilingListener != nil {
p.profilingListener.Close()
p.profilingListener = nil
}
p.srvProfiling = nil
}
if cfg.Profiling != nil {
@@ -196,12 +162,7 @@ func (p *program) run(cfg *config.Config) (err error) {
s := &http.Server{
Addr: addr,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
p.srvProfiling = s
p.profilingListener = ln
go func() {
defer s.Close()
@@ -209,7 +170,7 @@ func (p *program) run(cfg *config.Config) (err error) {
log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@profiling"})
log.Info("listening on ", addr)
if err := s.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Error(err)
}
}()
@@ -223,45 +184,30 @@ func (p *program) Stop() error {
p.cancel()
}
if p.reporter != nil {
p.reporter.Stop()
}
unlock := config.LockMutation()
defer unlock()
p.stopped = true
p.stopRuntime()
return nil
}
func (p *program) stopRuntime() {
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
for name, srv := range registry.ServiceRegistry().GetAll() {
srv.Close()
logger.Default().Debugf("service %s shutdown", name)
}
if p.srvApi != nil {
p.srvApi.Close()
p.srvApi = nil
logger.Default().Debug("service @api shutdown")
}
if p.srvMetrics != nil {
p.srvMetrics.Close()
p.srvMetrics = nil
logger.Default().Debug("service @metrics shutdown")
}
if p.srvProfiling != nil {
p.srvProfiling.Close()
if p.profilingListener != nil {
p.profilingListener.Close()
p.profilingListener = nil
}
p.srvProfiling = nil
logger.Default().Debug("service @profiling shutdown")
}
return nil
}
func (p *program) reload(ctx context.Context, c chan os.Signal) {
defer signal.Stop(c)
func (p *program) reload(ctx context.Context) {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
for {
select {
@@ -279,16 +225,13 @@ func (p *program) reload(ctx context.Context, c chan os.Signal) {
}
func (p *program) reloadConfig() error {
unlock := config.LockMutation()
defer unlock()
if p.stopped {
return errors.New("agent is shutting down")
}
cfg, err := parser.Parse()
if err != nil {
return err
}
if err := loader.Reload(cfg, p.run); err != nil {
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
return err
}
activeServices := make(map[string]struct{}, len(cfg.Services))
@@ -299,6 +242,10 @@ func (p *program) reloadConfig() error {
}
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
if err := p.run(cfg); err != nil {
return err
}
return nil
}
-313
View File
@@ -1,313 +0,0 @@
//go:build linux || darwin
package lifecycle_test
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/gorilla/websocket"
)
// Exercise the real binary: initial parsing is held at a FIFO while the panel
// tries to send a rule as soon as the WebSocket connects. This reproduced the
// old startup overwrite reliably without timing a large config load.
func TestAgentStartupAndFailedReloadPreservePanelRules(t *testing.T) {
if testing.Short() {
t.Skip("builds and runs the real agent")
}
binary := filepath.Join(t.TempDir(), "gost")
build := exec.Command("go", "build", "-o", binary, "../..")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build: %v\n%s", err, output)
}
dir := t.TempDir()
early, baseline := address(t), address(t)
apiAddr := address(t)
panelConnections := make(chan *websocket.Conn, 2)
wsReady := make(chan struct{})
responses := make(chan map[string]any, 8)
var ready sync.Once
upgrader := websocket.Upgrader{}
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/system-info" {
w.Write([]byte("ok"))
return
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
ready.Do(func() { close(wsReady) })
panelConnections <- c
if err := c.WriteJSON(map[string]any{"type": "AddService", "requestId": "early-rule", "data": []any{service("70_1_0", early)}}); err != nil {
return
}
for {
_, payload, err := c.ReadMessage()
if err != nil {
return
}
env := decodeResponse(t, payload)
if env["requestId"] == "early-rule" {
responses <- env
}
}
}))
defer panel.Close()
writeJSON(t, filepath.Join(dir, "config.json"), map[string]any{"addr": panel.URL, "secret": "audit-secret", "http": 1, "tls": 1, "socks": 1})
fifo := filepath.Join(dir, "delayed.json")
if err := syscall.Mkfifo(fifo, 0600); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(dir, "agent.log")
logfile, err := os.Create(logPath)
if err != nil {
t.Fatal(err)
}
defer logfile.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
agent := exec.CommandContext(ctx, binary, "-C", fifo)
agent.Dir, agent.Stdout, agent.Stderr = dir, logfile, logfile
if err := agent.Start(); err != nil {
t.Fatal(err)
}
exited := make(chan error, 1)
go func() { exited <- agent.Wait() }()
defer func() {
agent.Process.Kill()
if t.Failed() {
b, _ := os.ReadFile(logPath)
t.Logf("agent log:\n%s", b)
}
}()
select {
case <-wsReady:
t.Fatal("panel connected before initial config loaded")
case err := <-exited:
t.Fatalf("agent exited during startup: %v", err)
case <-time.After(500 * time.Millisecond):
}
boot := map[string]any{"services": []any{service("71_1_0", baseline)}, "api": map[string]any{"addr": apiAddr}}
writeJSON(t, filepath.Join(dir, "gost.json"), boot)
writeFIFO(t, fifo, boot)
select {
case <-wsReady:
case <-time.After(10 * time.Second):
t.Fatal("panel did not connect after startup")
}
select {
case response := <-responses:
if response["success"] != true {
t.Fatalf("AddService failed: %v", response)
}
case <-time.After(5 * time.Second):
t.Fatal("missing AddService response")
}
await(t, "both startup and panel listeners", func() bool { return listening(early) && listening(baseline) })
saved, err := os.ReadFile(fifo)
if err != nil || !strings.Contains(string(saved), "70_1_0") {
t.Fatalf("acknowledged rule not persisted: %v", err)
}
// A valid listener plus an invalid handler also exercises rollback of a
// partially initialized service which already bound the original port.
invalid := service("71_1_0", baseline)
invalid["handler"] = map[string]any{"type": "handler-does-not-exist"}
// The first persisted mutation atomically replaced the FIFO with a regular
// config file, so subsequent reloads use the same real persistence path.
writeJSON(t, fifo, map[string]any{"services": []any{invalid}})
if err := agent.Process.Signal(syscall.SIGHUP); err != nil {
t.Fatal(err)
}
await(t, "failed reload rollback", func() bool {
b, _ := os.ReadFile(logPath)
return strings.Contains(string(b), "previous config restored")
})
if !listening(early) || !listening(baseline) {
t.Fatal("failed reload lost a previously acknowledged listener")
}
// Starting the candidate API on the old service port must not prevent
// rollback when a later auxiliary listener fails to initialize.
logBefore, _ := os.ReadFile(logPath)
writeJSON(t, fifo, map[string]any{
"services": []any{service("candidate", address(t))},
"api": map[string]any{"addr": baseline},
"metrics": map[string]any{"addr": "127.0.0.1:not-a-port"},
})
if err := agent.Process.Signal(syscall.SIGHUP); err != nil {
t.Fatal(err)
}
await(t, "auxiliary listener rollback", func() bool {
b, _ := os.ReadFile(logPath)
return strings.Count(string(b), "previous config restored") > strings.Count(string(logBefore), "previous config restored")
})
if !listening(early) || !listening(baseline) || !listening(apiAddr) {
t.Fatal("candidate auxiliary listener prevented rollback")
}
// An authenticated API request that never finishes its body must not hold
// the runtime transaction lock against WS mutations or process shutdown.
slow, err := net.Dial("tcp", apiAddr)
if err != nil {
t.Fatal(err)
}
defer slow.Close()
if _, err = fmt.Fprintf(slow, "POST /config/services HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic dGVzdDp0ZXN0\r\nContent-Type: application/json\r\nContent-Length: 100000\r\n\r\n{"); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
panelConn := <-panelConnections
if err := panelConn.WriteJSON(map[string]any{"type": "DeleteService", "requestId": "slow-body-check", "data": map[string]any{"services": []string{"70_1_0"}}}); err != nil {
t.Fatal(err)
}
await(t, "WS mutation despite slow API upload", func() bool { return !listening(early) })
if err := agent.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
select {
case err := <-exited:
if err != nil {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("agent did not stop")
}
if listening(early) || listening(baseline) {
t.Fatal("shutdown left listeners open")
}
// Startup errors must exit without advertising a node ready to accept rules.
bad := filepath.Join(dir, "invalid.json")
writeJSON(t, bad, map[string]any{"services": []any{invalid}})
failed := exec.CommandContext(ctx, binary, "-C", bad)
failed.Dir = dir
if output, err := failed.CombinedOutput(); err == nil {
t.Fatalf("invalid startup succeeded: %s", output)
}
select {
case response := <-responses:
t.Fatalf("failed startup accepted panel command: %v", response)
default:
}
}
func service(name, addr string) map[string]any {
return map[string]any{"name": name, "addr": addr, "listener": map[string]any{"type": "tcp"}, "handler": map[string]any{"type": "auto"}}
}
func address(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().String()
}
func listening(addr string) bool {
c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if err != nil {
return false
}
c.Close()
return true
}
func await(t *testing.T, label string, pred func() bool) {
t.Helper()
for until := time.Now().Add(5 * time.Second); time.Now().Before(until); time.Sleep(10 * time.Millisecond) {
if pred() {
return
}
}
t.Fatal("timeout: " + label)
}
func writeJSON(t *testing.T, path string, value any) {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, b, 0600); err != nil {
t.Fatal(err)
}
}
func writeFIFO(t *testing.T, path string, value any) {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
done <- err
return
}
_, err = f.Write(b)
f.Close()
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("agent did not read config FIFO")
}
}
func decodeResponse(t *testing.T, payload []byte) map[string]any {
t.Helper()
env := map[string]any{}
if err := json.Unmarshal(payload, &env); err != nil {
t.Error(err)
return nil
}
if encrypted, _ := env["encrypted"].(bool); !encrypted {
return env
}
encoded, _ := env["data"].(string)
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Error(err)
return nil
}
hash := sha256.Sum256([]byte("audit-secret"))
block, _ := aes.NewCipher(hash[:])
gcm, _ := cipher.NewGCM(block)
if len(raw) < gcm.NonceSize() {
t.Error("short encrypted response")
return nil
}
payload, err = gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
t.Error(err)
return nil
}
env = map[string]any{}
if err := json.Unmarshal(payload, &env); err != nil {
t.Error(err)
return nil
}
return env
}
+1 -1
View File
@@ -52,7 +52,7 @@ func Register(r *gin.Engine, opts *Options) {
router.StaticFS("/docs", http.FS(swaggerDoc))
config := router.Group("/config")
config.Use(mwBasicAuth(opts.Auther), configTransaction())
config.Use(mwBasicAuth(opts.Auther))
config.GET("", getConfig)
config.POST("", saveConfig)
+10 -6
View File
@@ -37,12 +37,9 @@ func reloadConfig(ctx *gin.Context) {
return
}
if err := loader.Reload(cfg, func(*config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
go svc.Serve()
}
return nil
}); err != nil {
config.Set(cfg)
if err := loader.Load(cfg); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
return
}
@@ -54,6 +51,13 @@ func reloadConfig(ctx *gin.Context) {
}
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
for _, svc := range registry.ServiceRegistry().GetAll() {
svc := svc
go func() {
svc.Serve()
}()
}
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
-90
View File
@@ -1,90 +0,0 @@
package api
import (
"bytes"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
)
const maxConfigRequestBody = 16 << 20
// Read the complete bounded request before acquiring the runtime transaction
// lock. Buffer the response until after it is released: neither a slow upload
// nor a client that stops reading may block panel commands, reload or shutdown.
func configTransaction() gin.HandlerFunc {
return func(ctx *gin.Context) {
if ctx.Request.Body != nil {
body := http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxConfigRequestBody)
data, err := io.ReadAll(body)
body.Close()
if err != nil {
status := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
status = http.StatusRequestEntityTooLarge
}
ctx.AbortWithStatusJSON(status, Response{Code: status, Msg: "Unable to read configuration request"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewReader(data))
}
writer := ctx.Writer
buffered := &configResponseWriter{ResponseWriter: writer, header: writer.Header().Clone(), status: http.StatusOK, size: -1}
ctx.Writer = buffered
defer func() { ctx.Writer = writer }()
func() {
unlock := config.LockMutation()
defer unlock()
// A request waiting behind reload may have been closed during shutdown.
if ctx.Request.Context().Err() != nil {
ctx.Abort()
return
}
ctx.Next()
}()
ctx.Writer = writer
for key, values := range buffered.header {
writer.Header()[key] = values
}
writer.WriteHeader(buffered.status)
writer.Write(buffered.body.Bytes())
}
}
// Config endpoints return JSON rather than streaming. Preserve Gin's response
// bookkeeping while delaying all network writes until the transaction ends.
type configResponseWriter struct {
gin.ResponseWriter
header http.Header
body bytes.Buffer
status int
size int
}
func (w *configResponseWriter) Header() http.Header { return w.header }
func (w *configResponseWriter) WriteHeader(status int) {
if !w.Written() && status > 0 {
w.status = status
}
}
func (w *configResponseWriter) WriteHeaderNow() {
if !w.Written() {
w.size = 0
}
}
func (w *configResponseWriter) Write(p []byte) (int, error) {
w.WriteHeaderNow()
n, err := w.body.Write(p)
w.size += n
return n, err
}
func (w *configResponseWriter) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
func (w *configResponseWriter) Status() int { return w.status }
func (w *configResponseWriter) Size() int { return w.size }
func (w *configResponseWriter) Written() bool { return w.size >= 0 }
func (w *configResponseWriter) Flush() { w.WriteHeaderNow() }
-76
View File
@@ -1,76 +0,0 @@
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
)
func assertMutationAvailable(t *testing.T) {
t.Helper()
done := make(chan struct{})
go func() { unlock := config.LockMutation(); unlock(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("network I/O holds runtime mutation lock")
}
}
func TestConfigTransactionDoesNotLockWhileReadingBody(t *testing.T) {
router := gin.New()
router.Use(configTransaction())
router.POST("/config", func(c *gin.Context) { c.JSON(http.StatusOK, Response{Msg: "OK"}) })
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
request := httptest.NewRequest(http.MethodPost, "/config", reader)
done := make(chan struct{})
go func() { defer close(done); router.ServeHTTP(httptest.NewRecorder(), request) }()
if _, err := writer.Write([]byte("{")); err != nil {
t.Fatal(err)
}
assertMutationAvailable(t)
writer.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("handler did not finish")
}
}
type blockedResponse struct {
header http.Header
started chan struct{}
release chan struct{}
}
func (w *blockedResponse) Header() http.Header { return w.header }
func (w *blockedResponse) WriteHeader(int) {}
func (w *blockedResponse) Write(p []byte) (int, error) {
close(w.started)
<-w.release
return len(p), nil
}
func TestConfigTransactionReleasesLockBeforeSendingResponse(t *testing.T) {
router := gin.New()
router.Use(configTransaction())
router.POST("/config", func(c *gin.Context) { c.JSON(http.StatusOK, Response{Msg: "OK"}) })
writer := &blockedResponse{header: make(http.Header), started: make(chan struct{}), release: make(chan struct{})}
defer close(writer.release)
request := httptest.NewRequest(http.MethodPost, "/config", strings.NewReader("{}"))
go router.ServeHTTP(writer, request)
select {
case <-writer.started:
case <-time.After(time.Second):
t.Fatal("response did not start")
}
assertMutationAvailable(t)
}
+2 -11
View File
@@ -3,7 +3,6 @@ package service
import (
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-gost/core/auth"
@@ -68,11 +67,7 @@ func NewService(network, addr string, opts ...Option) (service.Service, error) {
return &server{
s: &http.Server{
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
Handler: r,
},
ln: ln,
cclose: make(chan struct{}),
@@ -88,11 +83,7 @@ func (s *server) Addr() net.Addr {
}
func (s *server) Close() error {
// Close can race the goroutine entering Serve during a failed startup.
// http.Server.Close alone does not own the listener until Serve starts.
err := s.s.Close()
s.ln.Close()
return err
return s.s.Close()
}
func (s *server) IsClosed() bool {
+2 -12
View File
@@ -23,20 +23,10 @@ func init() {
}
var (
global = &Config{}
globalMux sync.RWMutex
mutationMux sync.Mutex
global = &Config{}
globalMux sync.RWMutex
)
// LockMutation serializes complete runtime/config transactions across panel
// commands, management API requests, startup and reload. It is separate from
// globalMux so callers may safely use Global, Set and OnUpdate while holding it.
// Lock before reading/parsing the config and hold through persistence/rollback.
func LockMutation() func() {
mutationMux.Lock()
return mutationMux.Unlock
}
func Global() *Config {
globalMux.RLock()
defer globalMux.RUnlock()
-37
View File
@@ -1,8 +1,6 @@
package loader
import (
"fmt"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -32,34 +30,6 @@ func Load(cfg *config.Config) error {
return defaultLoader.Load(cfg)
}
// Reload replaces the runtime and commits the config only after it starts.
// The caller must hold config.LockMutation, including while parsing cfg, so the
// rollback snapshot includes every previously acknowledged runtime command.
// Failed loads can partially replace registries and bind listeners; always
// rebuild the last successful snapshot before returning an error.
func Reload(cfg *config.Config, run func(*config.Config) error) error {
previous := config.Global()
if err := apply(cfg, run); err != nil {
// A failed partial load may have left both old and new listeners behind.
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
}
if rollbackErr := apply(previous, run); rollbackErr != nil {
return fmt.Errorf("reload failed: %w; restore previous config failed: %v", err, rollbackErr)
}
return fmt.Errorf("reload failed (previous config restored): %w", err)
}
config.Set(cfg)
return nil
}
func apply(cfg *config.Config, run func(*config.Config) error) error {
if err := Load(cfg); err != nil {
return err
}
return run(cfg)
}
type loader struct{}
func (l *loader) Load(cfg *config.Config) error {
@@ -247,19 +217,12 @@ func register(cfg *config.Config) error {
registry.ServiceRegistry().Unregister(name)
}
for _, svcCfg := range cfg.Services {
if svcCfg == nil {
return fmt.Errorf("service config is nil")
}
if paused, _ := svcCfg.Metadata["paused"].(bool); paused {
continue
}
svc, err := service_parser.ParseService(svcCfg)
if err != nil {
return err
}
if svc != nil {
if err := registry.ServiceRegistry().Register(svcCfg.Name, svc); err != nil {
svc.Close()
return err
}
}
-84
View File
@@ -1,84 +0,0 @@
package loader_test
import (
"net"
"testing"
"time"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/loader"
_ "github.com/go-gost/x/handler/auto"
_ "github.com/go-gost/x/listener/tcp"
"github.com/go-gost/x/registry"
)
func TestReloadRestoresPreviousRuntime(t *testing.T) {
for _, failure := range []string{"listener", "handler", "partial", "run"} {
t.Run(failure, func(t *testing.T) {
unlock := config.LockMutation()
defer unlock()
original := config.Global()
defer config.Set(original)
defer func() {
for name := range registry.ServiceRegistry().GetAll() {
registry.ServiceRegistry().Unregister(name)
}
}()
old := &config.Config{Services: []*config.ServiceConfig{
{Name: "shared-rule", Addr: "127.0.0.1:0", Handler: &config.HandlerConfig{Type: "auto"}, Listener: &config.ListenerConfig{Type: "tcp"}},
{Name: "paused-rule", Addr: "127.0.0.1:0", Metadata: map[string]any{"paused": true}},
}}
if err := loader.Load(old); err != nil {
t.Fatal(err)
}
addr := registry.ServiceRegistry().Get("shared-rule").Addr().String()
old.Services[0].Addr = addr
config.Set(old)
serve := func(*config.Config) error {
for _, svc := range registry.ServiceRegistry().GetAll() {
go svc.Serve()
}
return nil
}
serve(old)
replacement := config.Global()
replacement.Services = replacement.Services[:1]
switch failure {
case "listener":
replacement.Services[0].Listener.Type = "invalid-listener"
case "handler":
replacement.Services[0].Handler.Type = "invalid-handler"
case "partial":
replacement.Services = append(replacement.Services, &config.ServiceConfig{Name: "broken", Listener: &config.ListenerConfig{Type: "invalid-listener"}})
}
run := serve
if failure == "run" {
run = func(cfg *config.Config) error {
if cfg == replacement {
return &net.AddrError{Err: "auxiliary listener failed", Addr: "test"}
}
return serve(cfg)
}
}
if err := loader.Reload(replacement, run); err == nil {
t.Fatal("expected reload failure")
}
restored := registry.ServiceRegistry().Get("shared-rule")
if restored == nil || restored.Addr().String() != addr {
t.Fatal("previous service was not restored on its original port")
}
if registry.ServiceRegistry().Get("paused-rule") != nil {
t.Fatal("rollback resumed a paused service")
}
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
t.Fatalf("restored listener unavailable: %v", err)
}
conn.Close()
got := config.Global()
if len(got.Services) != 2 || got.Services[0].Listener.Type != "tcp" || got.Services[0].Handler.Type != "auto" {
t.Fatalf("failed config was committed: %+v", got.Services)
}
})
}
}
-10
View File
@@ -255,15 +255,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err
}
// Listener initialization binds the port. If handler/TLS/forwarder parsing
// fails, release it so a reload rollback can restore the previous listener.
configured := false
defer func() {
if !configured {
ln.Close()
}
}()
handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler",
})
@@ -388,7 +379,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
serviceLogger.Infof("listening on %s/%s", s.Addr().String(), s.Addr().Network())
configured = true
return s, nil
}
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/go-gost/x
go 1.26.10
go 1.25.0
require github.com/shirou/gopsutil/v3 v3.24.5
+3 -21
View File
@@ -3,7 +3,6 @@ package udp
import (
"errors"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -74,7 +73,6 @@ func (ln *listener) listenLoop() {
n, raddr, err := ln.conn.ReadFrom(b)
if err != nil {
bufpool.Put(b)
ln.errChan <- err
close(ln.errChan)
return
@@ -112,27 +110,15 @@ func (ln *listener) Close() error {
}
func (ln *listener) getConn(raddr net.Addr) *conn {
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)
c, ok := ln.connPool.Get(raddr.String())
if ok && !c.isClosed() {
return c
}
c = newConn(ln.conn, laddr, peer, ln.config.ReadQueueSize, ln.config.Keepalive)
c.packet = packet
c = newConn(ln.conn, ln.Addr(), raddr, ln.config.ReadQueueSize, ln.config.Keepalive)
select {
case ln.cqueue <- c:
ln.connPool.Set(key, c)
ln.connPool.Set(raddr.String(), c)
return c
default:
c.Close()
@@ -146,7 +132,6 @@ type conn struct {
net.PacketConn
localAddr net.Addr
remoteAddr net.Addr
packet *packetAddr
rc chan []byte // data receive queue
idle int32 // indicate the connection is idle
closed chan struct{}
@@ -191,9 +176,6 @@ func (c *conn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
if !c.keepalive {
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)
}
-12
View File
@@ -1,12 +0,0 @@
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
}
-116
View File
@@ -1,116 +0,0 @@
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)
}
@@ -1,304 +0,0 @@
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
}
}
@@ -1,9 +0,0 @@
//go:build !linux
package udp
import "net"
func PreserveDestination(c *net.UDPConn) (net.PacketConn, error) {
return c, nil
}
@@ -1,289 +0,0 @@
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())
}
}
+2 -6
View File
@@ -56,15 +56,11 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
return
}
socket, err := net.ListenUDP(network, laddr)
var conn net.PacketConn
conn, err = net.ListenUDP(network, laddr)
if err != nil {
return
}
conn, err := udp.PreserveDestination(socket)
if err != nil {
socket.Close()
return err
}
conn = metrics.WrapPacketConn(l.options.Service, conn)
conn = stats.WrapPacketConn(conn, l.options.Stats)
conn = admission.WrapPacketConn(l.options.Admission, conn)
+1 -3
View File
@@ -84,9 +84,7 @@ func (s *metricService) Addr() net.Addr {
}
func (s *metricService) Close() error {
err := s.s.Close()
s.ln.Close()
return err
return s.s.Close()
}
func (s *metricService) IsClosed() bool {
+30 -30
View File
@@ -370,43 +370,43 @@ type getConfigResponse struct {
// getConfigData 获取配置数据(避免循环依赖)
func getConfigData() ([]byte, error) {
// Reporting is read-only: enriching a detached snapshot must not persist
// stale state over a config currently being reloaded or acknowledged.
cfg := config.Global()
for _, svc := range cfg.Services {
if svc == nil {
continue
}
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
config.OnUpdate(func(c *config.Config) error {
for _, svc := range c.Services {
if svc == nil {
continue
}
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
s := registry.ServiceRegistry().Get(svc.Name)
ss, ok := s.(serviceStatus)
if ok && ss != nil {
status := ss.Status()
svc.Status = &config.ServiceStatus{
CreateTime: status.CreateTime().Unix(),
State: string(status.State()),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
if st := status.Stats(); st != nil {
svc.Status.Stats = &config.ServiceStats{
TotalConns: st.Get(stats.KindTotalConns),
CurrentConns: st.Get(stats.KindCurrentConns),
TotalErrs: st.Get(stats.KindTotalErrs),
InputBytes: st.Get(stats.KindInputBytes),
OutputBytes: st.Get(stats.KindOutputBytes),
}
}
for _, ev := range status.Events() {
if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
Time: ev.Time.Unix(),
Msg: ev.Message,
})
}
}
}
}
}
return nil
})
var resp getConfigResponse
resp.Config = cfg
resp.Config = config.Global()
buf := &bytes.Buffer{}
resp.Config.Write(buf, "json")
@@ -1,14 +1,10 @@
package service
import (
"bytes"
"context"
"errors"
"github.com/go-gost/x/config"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -152,31 +148,3 @@ func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
t.Fatalf("expected remembered http url first, got %s", calls[0])
}
}
func TestConfigReportDoesNotPersistStaleRuntime(t *testing.T) {
previous, path := config.Global(), config.PersistPath()
defer config.Set(previous)
defer config.SetPersistPath(path)
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: "old-runtime"}}})
filename := filepath.Join(t.TempDir(), "gost.json")
candidate := []byte(`{"services":[{"name":"new-on-disk"}]}`)
if err := os.WriteFile(filename, candidate, 0600); err != nil {
t.Fatal(err)
}
config.SetPersistPath(filename)
config.EnablePersist()
report, err := getConfigData()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(report, []byte("old-runtime")) {
t.Fatalf("unexpected report: %s", report)
}
saved, err := os.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(saved, candidate) {
t.Fatalf("report overwrote candidate config: %s", saved)
}
}
-36
View File
@@ -115,39 +115,3 @@ func TestMutationQueueExecutesCommandsInArrivalOrder(t *testing.T) {
}
close(second.release)
}
func TestMutationCommandWaitsForRuntimeTransaction(t *testing.T) {
original := config.Global()
defer config.Set(original)
name := "reload_transaction_service"
svc := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
close(svc.release)
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
t.Fatal(err)
}
defer registry.ServiceRegistry().Unregister(name)
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: name}}})
reporter := NewWebSocketReporter("", "transaction-test-secret")
defer reporter.Stop()
unlock := config.LockMutation()
done := make(chan struct{})
go func() {
defer close(done)
reporter.routeCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{name}}})
}()
select {
case <-svc.started:
unlock()
t.Fatal("command interleaved with runtime transaction")
case <-time.After(50 * time.Millisecond):
}
unlock()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("command did not resume after transaction")
}
if registry.ServiceRegistry().Get(name) != nil {
t.Fatal("service was not removed")
}
}
+5 -26
View File
@@ -179,7 +179,6 @@ type WebSocketReporter struct {
tcpPingSem chan struct{} // 限制诊断探测并发,避免离线目标耗尽连接
readCommandSem chan struct{} // 限制只读命令并发,避免诊断请求耗尽资源
mutationQueue chan CommandMessage
workers sync.WaitGroup
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
@@ -239,15 +238,8 @@ func (w *WebSocketReporter) releaseTCPPingSlot() {
// Start 启动WebSocket报告器
func (w *WebSocketReporter) Start() {
w.workers.Add(2)
go func() {
defer w.workers.Done()
w.runMutationCommands()
}()
go func() {
defer w.workers.Done()
w.run()
}()
go w.runMutationCommands()
go w.run()
}
// Stop 停止WebSocket报告器
@@ -258,7 +250,6 @@ func (w *WebSocketReporter) Stop() {
w.conn.Close()
}
w.connMutex.Unlock()
w.workers.Wait()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
@@ -348,10 +339,10 @@ func (w *WebSocketReporter) connect() error {
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
dialer := *websocket.DefaultDialer
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, usedURL, err := dialWebSocketWithFallback(&dialer, candidates)
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
if err != nil {
return err
}
@@ -537,11 +528,7 @@ func (w *WebSocketReporter) handleConnection() {
}()
// 启动消息接收goroutine
w.workers.Add(1)
go func() {
defer w.workers.Done()
w.receiveMessages()
}()
go w.receiveMessages()
// 指标上报 ticker
metricTicker := time.NewTicker(w.pingInterval)
@@ -900,14 +887,6 @@ func isMutationCommand(commandType string) bool {
// routeCommand 路由命令到对应的处理函数
func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
if isMutationCommand(cmd.Type) {
unlock := config.LockMutation()
defer unlock()
if w.ctx.Err() != nil {
w.sendCommandFailure(cmd, "Agent is shutting down")
return
}
}
jsonBytes, errs := json.Marshal(cmd)
if errs != nil {
fmt.Println("Error marshaling JSON:", errs)
-3
View File
@@ -1,9 +1,6 @@
{
"name": "@flvx/skill-api",
"version": "2.1.9-rc4",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
+1 -2
View File
@@ -1,2 +1 @@
package-lock=true
engine-strict=true
package-lock=true
+1 -2
View File
@@ -1,6 +1,5 @@
# 多阶段构建 - 构建阶段
# CVE-2026-111497: use the patched Node.js Brotli decoder during builds.
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS builder
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
WORKDIR /app
-3
View File
@@ -3,9 +3,6 @@
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.28.1",
"engines": {
"node": "^22.23.4 || ^24.22.1 || ^26.12.1"
},
"type": "module",
"scripts": {
"dev": "vite",
-38
View File
@@ -70,44 +70,6 @@ export interface LoginResponse {
export const login = (data: LoginData) =>
Network.post<LoginResponse>("/user/login", data);
export interface PasskeyOptions {
sessionId: string;
options: { publicKey: Record<string, unknown> };
}
export interface PasskeyItem {
id: string;
name: string;
createdAt: number;
lastUsedAt: number;
}
export const getPasskeyStatus = () =>
Network.post<{ enabled: boolean }>("/user/passkey/status");
export const beginPasskeyLogin = () =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", {});
export const finishPasskeyLogin = (sessionId: string, credential: unknown) =>
Network.post<LoginResponse>("/user/passkey/login/finish", {
sessionId,
credential,
});
export const beginPasskeyRegistration = (password: string) =>
Network.post<PasskeyOptions>("/user/passkey/register/begin", { password });
export const finishPasskeyRegistration = (
sessionId: string,
credential: unknown,
name: string,
) =>
Network.post("/user/passkey/register/finish", {
sessionId,
credential,
name,
});
export const listPasskeys = () =>
Network.post<PasskeyItem[]>("/user/passkey/list");
export const deletePasskey = (id: string, password: string) =>
Network.post("/user/passkey/delete", { id, password });
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: UserMutationPayload) =>
Network.post("/user/create", data);
@@ -1,168 +0,0 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
listPasskeys,
type PasskeyItem,
} from "@/api";
import { createPasskey } from "@/utils/passkey";
export function PasskeyManager() {
const [enabled, setEnabled] = useState(false);
const [items, setItems] = useState<PasskeyItem[]>([]);
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const refresh = () =>
listPasskeys()
.then((res) => {
if (res.code === 0) setItems(res.data || []);
})
.catch(() => setItems([]));
useEffect(() => {
if (
!window.isSecureContext ||
typeof window.PublicKeyCredential === "undefined"
)
return;
getPasskeyStatus()
.then((res) => {
if (res.code === 0 && res.data.enabled) {
setEnabled(true);
void refresh();
}
})
.catch(() => setEnabled(false));
}, []);
if (!enabled) return null;
const register = async () => {
if (!password) {
toast.error("请输入当前密码");
return;
}
setBusy(true);
try {
const begin = await beginPasskeyRegistration(password);
if (begin.code !== 0) {
toast.error(begin.msg || "无法绑定通行证密钥");
return;
}
const credential = await createPasskey(begin.data.options);
const finish = await finishPasskeyRegistration(
begin.data.sessionId,
credential,
name.trim(),
);
if (finish.code !== 0) {
toast.error(finish.msg || "绑定失败");
return;
}
toast.success("通行证密钥已绑定");
setPassword("");
setName("");
await refresh();
} catch {
toast.error("绑定已取消或失败");
} finally {
setBusy(false);
}
};
const remove = async (id: string) => {
if (!password) {
toast.error("请输入当前密码以删除密钥");
return;
}
setBusy(true);
try {
const result = await deletePasskey(id, password);
if (result.code !== 0) {
toast.error(result.msg || "删除失败");
return;
}
toast.success("通行证密钥已删除");
setPassword("");
await refresh();
} catch {
toast.error("删除失败");
} finally {
setBusy(false);
}
};
return (
<Card>
<CardBody className="p-4 space-y-4">
<div>
<h3 className="text-base font-medium">通行证密钥</h3>
<p className="text-sm text-default-500">
绑定后可使用设备解锁登录,无需 Cloudflare
验证。绑定和删除均需输入当前密码。
</p>
</div>
<Input
label="当前密码"
type="password"
value={password}
variant="bordered"
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="密钥名称(可选)"
value={name}
variant="bordered"
onChange={(e) => setName(e.target.value)}
/>
<Button disabled={busy} onPress={register}>
绑定通行证密钥
</Button>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-lg border border-default-200 p-3"
>
<div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-xs text-default-500">
创建于 {new Date(item.createdAt).toLocaleDateString()}{" "}
{item.lastUsedAt
? ` · 最近使用 ${new Date(item.lastUsedAt).toLocaleDateString()}`
: ""}
</div>
</div>
<Button
color="danger"
disabled={busy}
size="sm"
variant="light"
onPress={() => void remove(item.id)}
>
删除
</Button>
</div>
))}
</div>
</CardBody>
</Card>
);
}
+2 -63
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile";
@@ -10,17 +10,8 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { BrandLogo } from "@/components/brand-logo";
import {
login,
LoginData,
checkCaptcha,
getPublicConfigByName,
getPasskeyStatus,
beginPasskeyLogin,
finishPasskeyLogin,
} from "@/api";
import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
import { getPasskey } from "@/utils/passkey";
import { useWebViewMode } from "@/hooks/useWebViewMode";
interface LoginForm {
@@ -39,51 +30,9 @@ export default function IndexPage() {
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const [passkeyEnabled, setPasskeyEnabled] = useState(false);
const navigate = useNavigate();
const isWebView = useWebViewMode();
useEffect(() => {
if (
window.isSecureContext &&
typeof window.PublicKeyCredential !== "undefined"
) {
getPasskeyStatus()
.then((res) => setPasskeyEnabled(res.code === 0 && res.data.enabled))
.catch(() => setPasskeyEnabled(false));
}
}, []);
const handlePasskeyLogin = async () => {
setLoading(true);
try {
const begin = await beginPasskeyLogin();
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
return;
}
const credential = await getPasskey(begin.data.options);
const result = await finishPasskeyLogin(begin.data.sessionId, credential);
if (result.code !== 0) {
toast.error(result.msg || "通行证密钥登录失败");
return;
}
writeLoginSession(result.data);
toast.success("登录成功");
navigate(
result.data.requirePasswordChange ? "/change-password" : "/dashboard",
);
} catch {
toast.error("通行证密钥登录已取消或失败");
} finally {
setLoading(false);
}
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -272,16 +221,6 @@ export default function IndexPage() {
: "Signing in..."
: "Sign In"}
</Button>
{passkeyEnabled && (
<Button
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={() => void handlePasskeyLogin()}
>
选择通行证密钥登录
</Button>
)}
</div>
</CardBody>
</Card>
+4 -1
View File
@@ -1977,7 +1977,10 @@ export default function NodePage() {
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("single", node.id)}
onPress={() => {
setUpgradeTargetNodeId(node.id);
openUpgradeModal("single");
}}
>
升级
</Button>
-2
View File
@@ -19,7 +19,6 @@ import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
import { PasskeyManager } from "@/components/passkey-manager";
interface PasswordForm {
newUsername: string;
currentPassword: string;
@@ -242,7 +241,6 @@ export default function ProfilePage() {
</Card>
{/* 功能网格 */}
<PasskeyManager />
<Card>
<CardBody className="p-4">
<div className="grid grid-cols-3 gap-3">
+1 -27
View File
@@ -231,9 +231,6 @@ export default function UserPage() {
maxConn: 0,
});
const [userFormLoading, setUserFormLoading] = useState(false);
const [userSubmitWarning, setUserSubmitWarning] = useState<string | null>(
null,
);
const [userFlowInput, setUserFlowInput] = useState("1000");
const [userFlowUnit, setUserFlowUnit] = useState<TrafficUnit>("GB");
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
@@ -520,7 +517,6 @@ export default function UserPage() {
};
const handleAdd = () => {
setUserSubmitWarning(null);
setIsEdit(false);
setUserFlowInput("1000");
setUserFlowUnit("GB");
@@ -541,7 +537,6 @@ export default function UserPage() {
};
const handleEdit = async (user: User) => {
setUserSubmitWarning(null);
setIsEdit(true);
const trafficInput = trafficInputFor(user.flow, user.flowMiB);
@@ -623,7 +618,6 @@ export default function UserPage() {
}
setUserFormLoading(true);
setUserSubmitWarning(null);
try {
const submitData: any = {
...userForm,
@@ -666,19 +660,7 @@ export default function UserPage() {
await loadUsers();
}
} else {
const userDisabled =
isEdit &&
response.data !== null &&
typeof response.data === "object" &&
"userDisabled" in response.data &&
response.data.userDisabled === true;
if (userDisabled) {
setUserSubmitWarning(response.msg);
await loadUsers();
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
} catch {
toast.error(isEdit ? "更新失败" : "创建失败");
@@ -1550,14 +1532,6 @@ export default function UserPage() {
<ModalContent>
<ModalHeader>{isEdit ? "编辑用户" : "新增用户"}</ModalHeader>
<ModalBody>
{userSubmitWarning ? (
<div
className="whitespace-pre-wrap break-words rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-100"
role="alert"
>
{userSubmitWarning}
</div>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
isRequired
-97
View File
@@ -1,97 +0,0 @@
// Convert the WebAuthn JSON wire format to the browser's ArrayBuffer format.
function decode(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(padded), (character) =>
character.charCodeAt(0),
);
return bytes.buffer;
}
function encode(value: ArrayBuffer): string {
const bytes = new Uint8Array(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export async function createPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
user: { id: string };
excludeCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialCreationOptions = {
...(options.publicKey as unknown as PublicKeyCredentialCreationOptions),
challenge: decode(source.challenge),
user: {
...(source.user as unknown as PublicKeyCredentialUserEntity),
id: decode(source.user.id),
},
excludeCredentials: source.excludeCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未创建通行证密钥");
const result = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(result.attestationObject),
clientDataJSON: encode(result.clientDataJSON),
transports: result.getTransports?.() || [],
},
};
}
export async function getPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
allowCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialRequestOptions = {
...(options.publicKey as unknown as PublicKeyCredentialRequestOptions),
challenge: decode(source.challenge),
allowCredentials: source.allowCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未选择通行证密钥");
const result = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
authenticatorData: encode(result.authenticatorData),
clientDataJSON: encode(result.clientDataJSON),
signature: encode(result.signature),
userHandle: result.userHandle ? encode(result.userHandle) : null,
},
};
}