Compare commits

..

1 Commits

Author SHA1 Message Date
sagitchu cbec9a63da fix modal scroll position jumps 2026-08-07 22:34:17 +08:00
114 changed files with 1094 additions and 9552 deletions
+1 -13
View File
@@ -7,18 +7,6 @@ on:
branches: ['**']
jobs:
install-scripts:
name: Test Installer Scripts (systemd and Alpine OpenRC)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run installer regression tests
run: bash test-install-scripts-proxy.sh
- name: Test Alpine bootstrap and OpenRC lifecycle
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
frontend:
name: Build Frontend
runs-on: ubuntu-latest
@@ -34,7 +22,7 @@ jobs:
node-version: '20.19.0'
- name: Install pnpm
run: npm install -g pnpm@10.28.1
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
+1 -4
View File
@@ -229,7 +229,6 @@ jobs:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg KEYGEN_ACCOUNT_ID=${{ secrets.KEYGEN_ACCOUNT_ID }} \
--push \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
@@ -304,9 +303,6 @@ jobs:
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
- name: Verify release installer on Alpine OpenRC
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh /workspace/artifacts/install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
@@ -435,3 +431,4 @@ jobs:
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "✅ GOST 二进制文件更新完成"
-39
View File
@@ -71,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 -1
View File
@@ -70,7 +70,7 @@ Alpine Linux 最小化安装若未包含 `curl`,可使用系统自带的 `wget
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
```
脚本会在 Alpine 上自动安装 Bash、`curl` 和 CA 证书,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
脚本会在 Alpine 上自动安装 Bash,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
-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}
+1 -2
View File
@@ -7,8 +7,7 @@ RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
ARG KEYGEN_ACCOUNT_ID
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -ldflags="-X 'go-backend/internal/license.AccountID=${KEYGEN_ACCOUNT_ID}'" -o /out/paneld ./cmd/paneld
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM docker:27-cli AS dockercli
+1 -8
View File
@@ -3,13 +3,11 @@ module go-backend
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=
@@ -29,10 +29,6 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if value, gated := h.unlicensedPublicBrandValue(configName); gated {
response.WriteJSON(w, response.OK(map[string]string{"name": configName, "value": value}))
return
}
cfg, err := h.repo.GetConfigByName(configName)
if err != nil {
@@ -46,22 +42,3 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.OK(cfg))
}
func (h *Handler) unlicensedPublicBrandValue(configName string) (string, bool) {
switch configName {
case "app_name", "app_logo", "app_favicon", "hide_footer_brand":
default:
return "", false
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if isCommercial == "true" {
return "", false
}
if configName == "app_name" {
return "FLVX", true
}
if configName == "hide_footer_brand" {
return "false", true
}
return "", true
}
@@ -19,8 +19,6 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
seedConfigValue(t, r, "app_logo", "logo-data")
seedConfigValue(t, r, "app_favicon", "favicon-data")
seedConfigValue(t, r, "app_bg_image", "bg-data")
seedConfigValue(t, r, "app_bg_image_light", "light-bg-data")
seedConfigValue(t, r, "app_bg_image_dark", "dark-bg-data")
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
@@ -30,50 +28,6 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
for name, want := range map[string]string{
"app_bg_image_light": "light-bg-data",
"app_bg_image_dark": "dark-bg-data",
} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, name, want)
}
}
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "app_name", "Paid Brand")
seedConfigValue(t, r, "app_logo", "logo-data")
seedConfigValue(t, r, "app_favicon", "favicon-data")
seedConfigValue(t, r, "hide_footer_brand", "true")
seedConfigValue(t, r, "is_commercial", "false")
for name, want := range map[string]string{
"app_name": "FLVX",
"app_logo": "",
"app_favicon": "",
"hide_footer_brand": "false",
} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, name, want)
}
}
func TestPublicBrandConfigUsesSavedValuesWithCommercialLicense(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "app_name", "Paid Brand")
seedConfigValue(t, r, "is_commercial", "true")
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, "app_name", "Paid Brand")
}
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
@@ -128,56 +82,6 @@ func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
}
func TestConfigGetNeverReturnsLicenseCredentials(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
seedConfigValue(t, r, "license_key", "license-secret")
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
for _, name := range []string{"license_key", "license_machine_id", "machine_fingerprint"} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, 403, "禁止访问系统授权凭据")
}
}
func TestConfigListNeverReturnsLicenseCredentials(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
seedConfigValue(t, r, "license_key", "license-secret")
seedConfigValue(t, r, "license_machine_id", "machine-id")
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
seedConfigValue(t, r, "is_commercial", "true")
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/list", nil)
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out struct {
Code int `json:"code"`
Data map[string]string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 || out.Data["is_commercial"] != "true" {
t.Fatalf("unexpected config response: %+v", out)
}
if _, ok := out.Data["license_key"]; ok {
t.Fatal("license_key must not be returned")
}
if _, ok := out.Data["license_machine_id"]; ok {
t.Fatal("license_machine_id must not be returned")
}
if _, ok := out.Data["machine_fingerprint"]; ok {
t.Fatal("machine_fingerprint must not be returned")
}
}
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
router, _ := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
@@ -250,7 +154,7 @@ func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
}
}
func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
@@ -261,13 +165,18 @@ func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
assertHandlerCode(t, resp, 0)
cfg, err := r.GetConfigByName("license_key")
if err != nil {
t.Fatalf("get config: %v", err)
}
if cfg == nil || cfg.Value != "license-secret" {
t.Fatalf("expected license_key to be updated, got %#v", cfg)
}
}
func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
@@ -278,9 +187,14 @@ func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
assertHandlerCode(t, resp, 0)
cfg, err := r.GetConfigByName("license_key")
if err != nil {
t.Fatalf("get config: %v", err)
}
if cfg == nil || cfg.Value != "license-secret" {
t.Fatalf("expected license_key to be updated, got %#v", cfg)
}
}
+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)
}
})
}
}
+202 -233
View File
@@ -4,34 +4,20 @@ import (
"encoding/json"
"errors"
"log"
"math"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
const bytesPerMiB int64 = 1024 * 1024
func flowLimitBytes(flowGB, flowMiB int64) int64 {
if flowMiB > 0 {
if flowMiB > math.MaxInt64/bytesPerMiB {
return math.MaxInt64
}
return flowMiB * bytesPerMiB
}
if flowGB > math.MaxInt64/bytesPerGB {
return math.MaxInt64
}
return flowGB * bytesPerGB
}
type userTunnelPolicy struct {
ID int64
UserID int64
TunnelID int64
Flow int64
FlowMiB int64
InFlow int64
OutFlow int64
ExpTime int64
@@ -46,22 +32,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) {
@@ -136,49 +138,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 {
@@ -193,26 +219,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)
}
}
@@ -220,8 +283,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)
}
}
@@ -281,7 +358,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("账号已过期")
}
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
@@ -323,7 +400,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("该隧道已过期")
}
utFlowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
utFlowLimit := policy.Flow * bytesPerGB
utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发")
@@ -348,7 +425,7 @@ func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
return false
}
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return true
@@ -364,7 +441,7 @@ func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
return false
}
flowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
flowLimit := policy.Flow * bytesPerGB
current := policy.InFlow + policy.OutFlow
if current >= flowLimit {
return true
@@ -388,7 +465,7 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
}
return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
}, nil
}
@@ -438,102 +515,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
}
@@ -546,9 +564,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]
@@ -567,17 +591,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
}
@@ -586,28 +616,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)
@@ -625,78 +644,28 @@ 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
}
forward, err := h.getForwardRecord(forwardID)
if errors.Is(err, errForwardNotFound) {
return false, nil
}
return forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0, err
}
id, err := strconv.ParseInt(name, 10, 64)
if err != nil || id <= 0 {
return false, 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)
@@ -1,38 +0,0 @@
package handler
import (
"path/filepath"
"testing"
"go-backend/internal/store/repo"
)
func TestSpeedLimiterExistsPreservesForwardRuleLimiter(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, ip_speed_id)
VALUES(8, 1, 'user', 'forward', 1, '127.0.0.1:80', 'fifo', 0, 0, 1, 1, 1, 0, 3),
(9, 1, 'user', 'forward-without-ip-limit', 1, '127.0.0.1:81', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
`).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
if !h.speedLimiterExists("rule_traffic_limit_8") {
t.Fatal("expected runtime limiter for existing forward to be preserved")
}
if h.speedLimiterExists("rule_traffic_limit_9") {
t.Fatal("expected runtime limiter for forward without per-IP speed limit to be treated as orphaned")
}
if h.speedLimiterExists("rule_traffic_limit_10") {
t.Fatal("expected runtime limiter for missing forward to be treated as orphaned")
}
if h.speedLimiterExists("rule_traffic_limit_invalid") {
t.Fatal("expected malformed runtime limiter name to be treated as orphaned")
}
}
@@ -23,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,
+56 -72
View File
@@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -21,6 +20,7 @@ import (
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/license"
"go-backend/internal/metrics"
"go-backend/internal/monitoring"
runtimenft "go-backend/internal/runtime/nftables"
@@ -39,26 +39,20 @@ 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
jobsStarted bool
jobsWG sync.WaitGroup
fingerprintMu sync.Mutex
licenseValidationMu sync.Mutex
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
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 +112,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 +153,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)
@@ -414,10 +400,6 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
return
}
configName := strings.ToLower(strings.TrimSpace(req.Name))
if configName == "license_key" || configName == "license_machine_id" || configName == "machine_fingerprint" {
response.WriteJSON(w, response.Err(403, "禁止访问系统授权凭据"))
return
}
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
@@ -453,13 +435,11 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
return
}
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
claims, isAdmin := ctxClaims.(auth.Claims)
if !isAdmin || claims.RoleID != 0 {
cfgMap = repo.FilterSensitiveConfigs(cfgMap)
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
delete(cfgMap, "license_key")
delete(cfgMap, "cloudflare_secret_key")
delete(cfgMap, "jwt_secret")
}
delete(cfgMap, "license_key")
delete(cfgMap, "license_machine_id")
delete(cfgMap, "machine_fingerprint")
response.WriteJSON(w, response.OK(cfgMap))
}
@@ -731,7 +711,6 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName,
"status": t.Status,
"flow": t.Flow,
"flowMiB": t.FlowMiB,
"num": t.Num,
"expTime": t.ExpTime,
"flowResetTime": t.FlowResetTime,
@@ -888,7 +867,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)
}
@@ -899,16 +878,10 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
h.fingerprintMu.Lock()
defer h.fingerprintMu.Unlock()
fp, err := h.repo.GetViteConfigValue("machine_fingerprint")
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
@@ -935,35 +908,56 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
h.licenseValidationMu.Lock()
defer h.licenseValidationMu.Unlock()
valResp, err := h.validateLicenseForMachine(key)
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
log.Printf("license activation failed: %v", err)
response.WriteJSON(w, response.ErrDefault(licenseValidationErrorMessage(err)))
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
// Needs machine activation
client.Token = key
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
// Translate specific error messages or log them
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
// we can consider the license valid for our simple usecase
} else {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
expiry := valResp.Data.Attributes.Expiry
if expiry == "" {
expiry = "never"
}
licenseState := map[string]string{
"license_key": key,
"is_commercial": "true",
"license_expiry": expiry,
}
if valResp.MachineID != "" {
licenseState["license_machine_id"] = valResp.MachineID
}
if err := h.repo.UpsertConfigs(licenseState, now); err != nil {
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1005,10 +999,6 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if repo.IsSystemManagedConfigKey(key) {
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
return
}
if protectedKeys[key] && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
@@ -1051,10 +1041,6 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if repo.IsSystemManagedConfigKey(name) {
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
@@ -1226,7 +1212,6 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName,
"tunnelFlow": t.TunnelFlow,
"flow": t.Flow,
"flowMiB": t.FlowMiB,
"inFlow": t.InFlow,
"outFlow": t.OutFlow,
"num": t.Num,
@@ -1276,7 +1261,6 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"user": user.User,
"status": user.Status,
"flow": user.Flow,
"flowMiB": user.FlowMiB,
"inFlow": user.InFlow,
"outFlow": user.OutFlow,
"num": user.Num,
+12 -64
View File
@@ -4,6 +4,8 @@ import (
"context"
"log"
"time"
"go-backend/internal/license"
)
var nftablesTrafficCollectInterval = 30 * time.Second
@@ -21,7 +23,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,54 +34,10 @@ 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) {
defer h.jobsWG.Done()
h.validateLicenseJob()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
@@ -97,25 +55,22 @@ func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
h.licenseValidationMu.Lock()
defer h.licenseValidationMu.Unlock()
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" {
if key == "" || isCommercial != "true" {
return // Nothing to validate
}
valResp, err := h.validateLicenseForMachine(key)
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
// Network and decode failures have no validation response, so retain the
// current state as a grace period. A rejected machine binding still has
// the original invalid response and must not stay commercially enabled.
if licenseValidationErrorIsDefinitive(valResp, err) {
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
}
// Network error or timeout. Grace period by not revoking immediately here.
return
}
@@ -129,14 +84,7 @@ func (h *Handler) validateLicenseJob() {
if expiry == "" {
expiry = "never"
}
licenseState := map[string]string{
"is_commercial": "true",
"license_expiry": expiry,
}
if valResp.MachineID != "" {
licenseState["license_machine_id"] = valResp.MachineID
}
_ = h.repo.UpsertConfigs(licenseState, now)
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
}
}
@@ -1,109 +0,0 @@
package handler
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"go-backend/internal/license"
)
var newLicenseClient = license.NewKeygenClient
func keygenAccountID() string {
if value := strings.TrimSpace(license.AccountID); value != "" {
return value
}
return strings.TrimSpace(os.Getenv("KEYGEN_ACCOUNT_ID"))
}
func licenseNeedsMachineActivation(code string) bool {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
return true
default:
return false
}
}
func licenseValidationErrorIsDefinitive(validation *license.ValidateResponse, err error) bool {
if err == nil {
return validation != nil && !validation.Meta.Valid
}
var apiErr *license.APIError
if !errors.As(err, &apiErr) {
return false
}
if apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError {
return false
}
return apiErr.Operation == "activate machine" && validation != nil && !validation.Meta.Valid
}
func licenseValidationErrorMessage(err error) string {
if strings.Contains(err.Error(), "keygen account id is not configured") {
return "授权服务配置错误"
}
var apiErr *license.APIError
if errors.As(err, &apiErr) {
if apiErr.HasCode("MACHINE_LIMIT_EXCEEDED") {
return "授权设备数量已达上限"
}
if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
return "授权码无效或无权绑定设备"
}
}
return "连接授权服务器失败,请稍后重试"
}
func (h *Handler) validateLicenseForMachine(key string) (*license.ValidateResponse, error) {
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
return nil, fmt.Errorf("prepare machine fingerprint: %w", err)
}
storedMachineID, _ := h.repo.GetViteConfigValue("license_machine_id")
accountID := keygenAccountID()
if accountID == "" {
return nil, fmt.Errorf("keygen account id is not configured")
}
client := newLicenseClient(accountID, "")
var validation *license.ValidateResponse
if storedMachineID != "" {
validation, err = client.ValidateKeyWithMachine(key, fingerprint, storedMachineID)
} else {
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
}
if err != nil {
return nil, err
}
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
if validation.Meta.Valid {
validation.MachineID = storedMachineID
}
return validation, nil
}
client.Token = key
machineID, err := client.ActivateMachine(validation.Data.ID, fingerprint)
if err != nil {
return validation, err
}
if machineID == "" {
machineID, err = client.GetMachineID(fingerprint)
if err != nil {
return validation, fmt.Errorf("retrieve activated machine: %w", err)
}
}
validation, err = client.ValidateKeyWithMachine(key, fingerprint, machineID)
if err != nil {
return nil, err
}
if validation.Meta.Valid {
validation.MachineID = machineID
}
return validation, nil
}
@@ -1,358 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/license"
"go-backend/internal/store/repo"
)
func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
if validations.Add(1) == 1 {
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
return
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
fingerprint, err := r.GetViteConfigValue("machine_fingerprint")
if err != nil || strings.TrimSpace(fingerprint) == "" {
t.Fatalf("expected persisted machine fingerprint, got value=%q err=%v", fingerprint, err)
}
if got := validations.Load(); got != 2 {
t.Fatalf("validation calls = %d, want 2", got)
}
}
func TestValidateLicenseJobAcceptsExistingMachineActivation(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
if validations.Add(1) == 1 {
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
return
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "never")
if got := validations.Load(); got != 2 {
t.Fatalf("validation calls = %d, want 2", got)
}
}
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
r := openLicenseTestRepository(t)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
code := "NO_MACHINE"
if validations.Add(1) > 1 {
code = "FINGERPRINT_SCOPE_MISMATCH"
}
_, _ = fmt.Fprintf(w, `{"meta":{"valid":false,"code":%q},"data":{"id":"license-id","attributes":{}}}`, code)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
res := httptest.NewRecorder()
h.licenseActivate(res, req)
if !strings.Contains(res.Body.String(), "FINGERPRINT_SCOPE_MISMATCH") {
t.Fatalf("expected post-activation validation failure, got %s", res.Body.String())
}
assertLicenseConfig(t, r, "is_commercial", "false")
for _, name := range []string{"license_key", "license_expiry"} {
if value, err := r.GetViteConfigValue(name); err == nil || value != "" {
t.Fatalf("%s should not be persisted, got value=%q err=%v", name, value, err)
}
}
}
func TestLicenseActivatePersistsValidatedState(t *testing.T) {
r := openLicenseTestRepository(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
res := httptest.NewRecorder()
h.licenseActivate(res, req)
if !strings.Contains(res.Body.String(), `"code":0`) {
t.Fatalf("expected activation success, got %s", res.Body.String())
}
assertLicenseConfig(t, r, "license_key", "license-secret")
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
}
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "false")
}
func TestValidateLicenseJobRestoresCommercialStateWhenLicenseRecovers(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "false", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
return
}
http.NotFound(w, req)
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "never")
}
func TestValidateLicenseJobUsesStoredMachineScope(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
seedLicenseConfig(t, r, "license_machine_id", "machine-id", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
t.Fatalf("unexpected request %s %s", req.Method, req.URL.Path)
}
var body struct {
Meta struct {
Scope map[string]string `json:"scope"`
} `json:"meta"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Meta.Scope["machine"] != "machine-id" || body.Meta.Scope["fingerprint"] != "fingerprint" {
t.Fatalf("unexpected validation scope: %+v", body.Meta.Scope)
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_machine_id", "machine-id")
}
func TestValidateLicenseJobKeepsStateOnMachineLookupServerFailure(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"SERVICE_UNAVAILABLE"}]}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
}
func TestValidateLicenseJobKeepsStateOnMachineLookupNotFound(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
http.NotFound(w, req)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
}
func TestLicenseValidationErrorMessageDoesNotExposeKeygenResponse(t *testing.T) {
err := &license.APIError{
Operation: "activate machine",
StatusCode: http.StatusUnprocessableEntity,
Body: `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED","detail":"private detail"}]}`,
}
message := licenseValidationErrorMessage(err)
if message != "授权设备数量已达上限" || strings.Contains(message, "private detail") {
t.Fatalf("unexpected public error message %q", message)
}
}
func openLicenseTestRepository(t *testing.T) *repo.Repository {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
if err != nil {
t.Fatalf("repo.Open() error = %v", err)
}
t.Cleanup(func() { _ = r.Close() })
return r
}
func seedLicenseConfig(t *testing.T, r *repo.Repository, name, value string, now int64) {
t.Helper()
if err := r.UpsertConfig(name, value, now); err != nil {
t.Fatalf("UpsertConfig(%q) error = %v", name, err)
}
}
func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
t.Helper()
got, err := r.GetViteConfigValue(name)
if err != nil {
t.Fatalf("GetViteConfigValue(%q) error = %v", name, err)
}
if got != want {
t.Fatalf("config %q = %q, want %q", name, got, want)
}
}
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
t.Helper()
t.Setenv("KEYGEN_ACCOUNT_ID", "account-id")
previous := newLicenseClient
newLicenseClient = func(accountID, token string) *license.KeygenClient {
client := license.NewKeygenClient(accountID, token)
client.BaseURL = baseURL
return client
}
t.Cleanup(func() { newLicenseClient = previous })
}
+118 -357
View File
@@ -57,11 +57,7 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
}
status := asInt(req["status"], 1)
flow, flowMiB, flowErr := parseTrafficLimit(req, 100)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
flow := asInt64(req["flow"], 100)
num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
@@ -80,7 +76,7 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now, flowMiB)
userID, err := h.repo.CreateUser(username, hashedPassword, roleID, expTime, flow, flowResetTime, num, status, maxConn, now)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -168,16 +164,7 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
return
}
flow, flowMiB, flowErr := parseTrafficLimit(req, 100)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
if _, supplied := req["flowMiB"]; !supplied {
if current, err := h.repo.GetUserByID(id); err == nil && current != nil && current.Flow == flow {
flowMiB = current.FlowMiB
}
}
flow := asInt64(req["flow"], 100)
num := asInt(req["num"], 10)
expTime := asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli())
flowResetTime := asInt64(req["flowResetTime"], 1)
@@ -189,7 +176,7 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
pwd := asString(req["pwd"])
if strings.TrimSpace(pwd) == "" {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now, flowMiB); err != nil {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -199,13 +186,13 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now, flowMiB); err != nil {
if err := h.repo.UpdateUserWithPassword(id, username, hashedPassword, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime, flowMiB)
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)
if hasDailyQuota || hasMonthlyQuota {
dailyQuotaGB := asInt64(req["dailyQuotaGB"], 0)
monthlyQuotaGB := asInt64(req["monthlyQuotaGB"], 0)
@@ -827,10 +814,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
@@ -913,27 +896,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
}
@@ -941,11 +919,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
}
@@ -1103,38 +1078,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
@@ -1147,55 +1106,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
}
@@ -1203,13 +1124,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,
@@ -1225,27 +1151,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
}
@@ -1257,15 +1177,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
}
@@ -1290,7 +1207,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
@@ -1499,10 +1417,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
}
@@ -1513,7 +1428,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 {
@@ -1523,7 +1438,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 {
@@ -1675,11 +1590,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
@@ -1746,12 +1658,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)
@@ -1850,35 +1758,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
}
}
@@ -2084,32 +1991,14 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, oldErr.Error()))
return
}
oldTunnel, oldTunnelErr := h.repo.GetUserTunnelByID(id)
if oldTunnelErr != nil {
response.WriteJSON(w, response.Err(-2, oldTunnelErr.Error()))
return
}
if oldTunnel == nil {
response.WriteJSON(w, response.ErrDefault("隧道权限不存在"))
return
}
flow, flowMiB, flowErr := parseTrafficLimit(req, 0)
if flowErr != nil {
response.WriteJSON(w, response.ErrDefault(flowErr.Error()))
return
}
if _, supplied := req["flowMiB"]; !supplied && oldTunnel.Flow == flow {
flowMiB = oldTunnel.FlowMiB
}
if err := h.repo.UpdateUserTunnel(id,
flow,
asInt64(req["flow"], 0),
asInt(req["num"], 0),
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
asInt64(req["flowResetTime"], 1),
nullableInt(speedID),
asInt(req["status"], 1),
flowMiB,
); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -2124,7 +2013,6 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
oldFlowReset,
oldSpeedID,
oldStatus,
oldTunnel.FlowMiB,
)
if rollbackErr != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr)))
@@ -3473,13 +3361,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{}{}
}
}
}
@@ -3527,59 +3414,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)
@@ -3729,7 +3563,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)
@@ -3744,14 +3579,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]
@@ -3764,7 +3595,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
@@ -3784,7 +3616,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-- {
@@ -3797,7 +3635,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)
@@ -3812,14 +3651,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]
@@ -3833,14 +3668,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,
@@ -3859,7 +3697,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
@@ -3879,124 +3718,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) {
@@ -4996,14 +4781,6 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
}
reqFlow := asInt64(req["flow"], -1)
var reqFlowMiB int64
if _, hasFlowMiB := req["flowMiB"]; hasFlowMiB {
var flowErr error
reqFlow, reqFlowMiB, flowErr = parseTrafficLimit(req, 0)
if flowErr != nil {
return flowErr
}
}
reqNum := asInt(req["num"], -1)
reqExpTime := asInt64(req["expTime"], -1)
reqFlowReset := asInt64(req["flowResetTime"], -1)
@@ -5015,9 +4792,6 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
if uErr == nil {
if reqFlow < 0 {
reqFlow = uFlow
if user, err := h.repo.GetUserByID(userID); err == nil && user != nil {
reqFlowMiB = user.FlowMiB
}
}
if reqNum < 0 {
reqNum = uNum
@@ -5046,7 +4820,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqStatus = 1
}
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus, reqFlowMiB); err != nil {
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {
return err
}
@@ -5070,20 +4844,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
}
newFlow := currentFlow
oldTunnel, err := h.repo.GetUserTunnelByID(existingID)
if err != nil {
return err
}
if oldTunnel == nil {
return fmt.Errorf("隧道权限不存在")
}
newFlowMiB := oldTunnel.FlowMiB
if reqFlow >= 0 {
newFlow = reqFlow
newFlowMiB = reqFlowMiB
if _, supplied := req["flowMiB"]; !supplied && reqFlow == currentFlow {
newFlowMiB = oldTunnel.FlowMiB
}
}
newNum := int(currentNum)
@@ -5113,7 +4875,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
newSpeedID = sql.NullInt64{Valid: false}
}
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus, newFlowMiB); err != nil {
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {
return err
}
@@ -5126,7 +4888,6 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
currentExpTime,
currentFlowReset,
currentStatus,
oldTunnel.FlowMiB,
)
if rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr)
-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")
}
}
@@ -1,28 +0,0 @@
package handler
import (
"fmt"
"math"
"strconv"
)
// flowMiB is optional so older clients can keep sending the GB-based flow field.
// A positive value takes precedence and preserves sub-GB limits exactly.
func parseTrafficLimit(req map[string]interface{}, defaultGB int64) (flowGB, flowMiB int64, err error) {
flowGB = asInt64(req["flow"], defaultGB)
if flowGB < 0 {
return 0, 0, fmt.Errorf("流量限制不能小于0")
}
raw, present := req["flowMiB"]
if !present {
return flowGB, 0, nil
}
flowMiB, err = strconv.ParseInt(asString(raw), 10, 64)
if err != nil || flowMiB < 0 || flowMiB > math.MaxInt64/bytesPerMiB {
return 0, 0, fmt.Errorf("流量限制超出范围")
}
if flowMiB > 0 {
flowGB = (flowMiB-1)/1024 + 1
}
return flowGB, flowMiB, nil
}
@@ -1,39 +0,0 @@
package handler
import (
"testing"
"time"
)
func TestTrafficLimitMiBOverridesLegacyGB(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{
"flow": float64(1), "flowMiB": float64(500),
}, 100)
if err != nil || flowGB != 1 || flowMiB != 500 {
t.Fatalf("parseTrafficLimit = (%d, %d, %v), want (1, 500, nil)", flowGB, flowMiB, err)
}
limit := flowLimitBytes(flowGB, flowMiB)
if limit != 500*bytesPerMiB {
t.Fatalf("limit = %d, want %d", limit, 500*bytesPerMiB)
}
policy := &userTunnelPolicy{Flow: flowGB, FlowMiB: flowMiB, InFlow: limit - 1, Status: 1}
if shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy paused before reaching 500 MiB")
}
policy.InFlow = limit
if !shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy did not pause at 500 MiB")
}
}
func TestTrafficLimitLegacyAndInvalidValues(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{"flow": float64(2)}, 100)
if err != nil || flowGB != 2 || flowMiB != 0 || flowLimitBytes(flowGB, flowMiB) != 2*bytesPerGB {
t.Fatalf("legacy GB limit changed: (%d, %d, %v)", flowGB, flowMiB, err)
}
for _, value := range []interface{}{"1.5", -1, "999999999999999999999"} {
if _, _, err := parseTrafficLimit(map[string]interface{}{"flowMiB": value}, 100); err == nil {
t.Fatalf("accepted invalid flowMiB %v", value)
}
}
}
+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)
@@ -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":
@@ -184,8 +182,6 @@ func requiresAdmin(path string) bool {
return true
case "/api/v1/config/update", "/api/v1/config/update-single":
return true
case "/api/v1/license/activate":
return true
case "/api/v1/announcement/update":
return true
default:
@@ -197,39 +197,6 @@ func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
}
}
func TestLicenseActivateRequiresAdmin(t *testing.T) {
if !requiresAdmin("/api/v1/license/activate") {
t.Fatal("expected license activation to require admin")
}
}
func TestJWTRejectsNonAdminLicenseActivation(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(2, "regular_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
}
func TestJWTExpiresAfterSevenDays(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
+21 -127
View File
@@ -6,53 +6,24 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var AccountID string
type KeygenClient struct {
AccountID string
Token string
BaseURL string
HTTPClient *http.Client
}
type APIError struct {
Operation string
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
}
func (e *APIError) HasCode(code string) bool {
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
}
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
BaseURL: defaultAPIBaseURL,
AccountID: accountID,
Token: token,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *KeygenClient) apiURL(path string) string {
baseURL := strings.TrimRight(c.BaseURL, "/")
if baseURL == "" {
baseURL = defaultAPIBaseURL
}
return fmt.Sprintf("%s/accounts/%s/%s", baseURL, c.AccountID, strings.TrimLeft(path, "/"))
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
@@ -64,7 +35,6 @@ type ValidateResponse struct {
Expiry string `json:"expiry"`
} `json:"attributes"`
} `json:"data"`
MachineID string `json:"-"`
}
type ActivateMachineRequest struct {
@@ -84,37 +54,8 @@ type ActivateMachineRequest struct {
} `json:"data"`
}
type keygenErrorResponse struct {
Errors []struct {
Code string `json:"code"`
} `json:"errors"`
}
type MachineResponse struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
func hasKeygenErrorCode(body []byte, code string) bool {
var resp keygenErrorResponse
if err := json.Unmarshal(body, &resp); err != nil {
return false
}
for _, item := range resp.Errors {
if strings.EqualFold(strings.TrimSpace(item.Code), code) {
return true
}
}
return false
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
return c.ValidateKeyWithMachine(key, fingerprint, "")
}
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
url := c.apiURL("licenses/actions/validate-key")
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
meta := map[string]interface{}{
"key": key,
@@ -125,14 +66,6 @@ func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string
"fingerprint": fingerprint,
}
}
if machineID != "" {
scope, _ := meta["scope"].(map[string]interface{})
if scope == nil {
scope = make(map[string]interface{})
meta["scope"] = scope
}
scope["machine"] = machineID
}
reqBody := map[string]interface{}{
"meta": meta,
@@ -158,8 +91,7 @@ func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
@@ -170,44 +102,8 @@ func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string
return &valResp, nil
}
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.api+json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
}
var machineResp MachineResponse
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
return "", err
}
machineID := strings.TrimSpace(machineResp.Data.ID)
if machineID == "" {
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
}
return machineID, nil
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := c.apiURL("licenses/actions/validate-key")
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
reqBody := map[string]interface{}{
"meta": map[string]string{
@@ -234,8 +130,7 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
}
var valResp ValidateResponse
@@ -246,8 +141,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
return &valResp, nil
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
url := c.apiURL("machines")
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
@@ -270,24 +165,23 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, e
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var machineResp MachineResponse
if json.Unmarshal(body, &machineResp) == nil {
return strings.TrimSpace(machineResp.Data.ID), nil
return nil
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
// Machine already registered to this license or limit reached because it's already us.
// The subsequent ValidateKey check will determine if the existing machine is actually us.
return nil
}
return "", nil
}
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
// to the target license, so this means the same machine is already bound.
return "", nil
}
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
-104
View File
@@ -1,104 +0,0 @@
package license
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Meta struct {
Scope map[string]string `json:"scope"`
} `json:"meta"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Meta.Scope["fingerprint"] != "fingerprint" {
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
}
if body.Meta.Scope["machine"] != "machine-id" {
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "")
client.BaseURL = server.URL
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
if err != nil || !validation.Meta.Valid {
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
}
}
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "License license-key" {
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
}
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.GetMachineID("fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
}
}
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
}
}
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.ActivateMachine("license-id", "fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
}
}
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
_, err := client.ActivateMachine("license-id", "fingerprint")
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
}
}
@@ -46,8 +46,7 @@ func RenderTable(plan NodePlan) string {
}
targetHost := strings.Trim(strings.TrimSpace(rule.TargetHost), "[]")
for _, protocol := range normalizedProtocols(rule.Protocols) {
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n",
protocol,
b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n",
rule.InPort,
family,
targetHost,
@@ -55,8 +54,7 @@ func RenderTable(plan NodePlan) string {
rule.TargetPort,
counterComment(rule.ForwardID, CounterDirectionToTarget, protocol),
))
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n",
protocol,
b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n",
rule.InPort,
family,
targetHost,
@@ -62,10 +62,10 @@ func TestRenderTableIncludesForwardAccountingCounters(t *testing.T) {
wantLines := []string{
`tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"`,
`udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"`,
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
`meta l4proto tcp ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`,
`meta l4proto udp ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`,
`meta l4proto udp ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`,
`ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
`ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`,
`ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`,
`ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`,
}
for _, want := range wantLines {
if !strings.Contains(got, want) {
@@ -89,8 +89,8 @@ func TestRenderTableIncludesIPv6ForwardAccountingCounters(t *testing.T) {
got := RenderTable(plan)
wantLines := []string{
`tcp dport 12346 counter dnat ip6 to [2001:db8::20]:8443 comment "flvx forward:43 dnat tcp"`,
`meta l4proto tcp ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`,
`meta l4proto tcp ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`,
`ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`,
`ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`,
}
for _, want := range wantLines {
if !strings.Contains(got, want) {
@@ -110,8 +110,8 @@ func TestRenderTableAccountingCountersIncludeOriginalPort(t *testing.T) {
got := RenderTable(plan)
wantLines := []string{
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
`meta l4proto tcp ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`,
`ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
`ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`,
}
for _, want := range wantLines {
if !strings.Contains(got, want) {
+8 -43
View File
@@ -26,58 +26,23 @@ func NewSSHRunner() *SSHRunner {
}
func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error {
nft := nftBinary(cfg)
tableName := fmt.Sprintf("flvx_capability_%d", time.Now().UnixNano())
return r.run(ctx, cfg, buildCapabilityCheckCommand(nft, tableName))
}
func buildCapabilityCheckCommand(nft, tableName string) string {
script := RenderTable(NodePlan{
Rules: []Rule{{
ForwardID: 1,
InPort: 12345,
TargetHost: "192.0.2.1",
TargetPort: 443,
Protocols: []string{"tcp", "udp"},
}},
})
script = strings.Replace(script, "table inet flvx {", "table inet "+tableName+" {", 1)
return "set -eu\n" +
"command -v nft >/dev/null 2>&1\n" +
nft + " --version >/dev/null 2>&1\n" +
"tmp=$(mktemp /tmp/flvx-nft-capability-XXXXXX.nft)\n" +
"trap 'rm -f \"$tmp\"' EXIT\n" +
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
"if ! " + nft + " -c -f \"$tmp\"; then\n" +
" echo 'nftables cannot validate the generated FLVX rules' >&2\n" +
" exit 1\n" +
"fi"
return r.run(ctx, cfg, "command -v nft >/dev/null 2>&1 && nft --version >/dev/null 2>&1")
}
func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error {
command := buildApplyCommand(nftBinary(cfg), script)
return r.run(ctx, cfg, command)
}
func buildApplyCommand(nft, script string) string {
return "set -eu\n" +
"tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft)\n" +
"batch=$(mktemp /tmp/flvx-nft-batch-XXXXXX.nft) || { rm -f \"$tmp\"; exit 1; }\n" +
nft := nftBinary(cfg)
command := "tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft) || exit 1\n" +
"cleanup() {\n" +
" rm -f \"$tmp\" \"$batch\"\n" +
" rm -f \"$tmp\"\n" +
"}\n" +
"trap cleanup EXIT\n" +
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
nft + " -c -f \"$tmp\"\n" +
"if " + nft + " list table inet flvx >/dev/null 2>&1; then\n" +
" { printf '%s\\n' 'delete table inet flvx'; cat \"$tmp\"; } > \"$batch\"\n" +
"else\n" +
" cp \"$tmp\" \"$batch\"\n" +
" " + nft + " delete table inet flvx\n" +
"fi\n" +
"if ! " + nft + " -c -f \"$batch\"; then\n" +
" echo 'nftables rule validation failed; active rules were preserved' >&2\n" +
" exit 1\n" +
"fi\n" +
nft + " -f \"$batch\""
nft + " -f \"$tmp\""
return r.run(ctx, cfg, command)
}
func (r *SSHRunner) ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error) {
@@ -5,113 +5,10 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestBuildApplyCommandStopsAfterValidationFailure(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "calls.log")
applyMarker := filepath.Join(dir, "applied")
nftPath := filepath.Join(dir, "nft")
fake := `#!/bin/sh
echo "$*" >> "` + logPath + `"
if [ "$1" = "list" ]; then
exit 0
fi
if [ "$1" = "-c" ]; then
exit 1
fi
touch "` + applyMarker + `"
`
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
t.Fatalf("write fake nft: %v", err)
}
command := buildApplyCommand(nftPath, "table inet flvx { }")
result := exec.Command("sh", "-c", command)
if err := result.Run(); err == nil {
t.Fatal("expected validation failure")
}
if _, err := os.Stat(applyMarker); !os.IsNotExist(err) {
t.Fatalf("apply ran after validation failure, stat err=%v", err)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read fake nft calls: %v", err)
}
if strings.Count(string(calls), "-f ") != 1 {
t.Fatalf("expected validation only, got calls:\n%s", calls)
}
}
func TestBuildCapabilityCheckCommandValidatesRenderedRulesWithoutApplying(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "calls.log")
nftPath := filepath.Join(dir, "nft")
fake := `#!/bin/sh
echo "$*" >> "` + logPath + `"
if [ "$1" = "--version" ] || [ "$1" = "-c" ]; then
exit 0
fi
exit 1
`
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
t.Fatalf("write fake nft: %v", err)
}
command := strings.Replace(buildCapabilityCheckCommand(nftPath, "flvx_capability_test"), "command -v nft", "command -v "+nftPath, 1)
result := exec.Command("sh", "-c", command)
if output, err := result.CombinedOutput(); err != nil {
t.Fatalf("capability command failed: %v: %s", err, output)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read fake nft calls: %v", err)
}
if strings.Count(string(calls), "-c -f ") != 1 || strings.Contains(string(calls), "\n-f ") {
t.Fatalf("expected one check-only invocation, got calls:\n%s", calls)
}
if !strings.Contains(command, "table inet flvx_capability_test") || !strings.Contains(command, "meta l4proto tcp ct original proto-dst") {
t.Fatalf("capability check does not contain representative rendered rules:\n%s", command)
}
}
func TestBuildApplyCommandUsesAtomicReplacementBatch(t *testing.T) {
dir := t.TempDir()
batchPath := filepath.Join(dir, "batch.nft")
nftPath := filepath.Join(dir, "nft")
fake := `#!/bin/sh
if [ "$1" = "list" ]; then
exit 0
fi
if [ "$1" = "-f" ]; then
cp "$2" "` + batchPath + `"
fi
exit 0
`
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
t.Fatalf("write fake nft: %v", err)
}
script := "table inet flvx {\n chain forward { }\n}"
result := exec.Command("sh", "-c", buildApplyCommand(nftPath, script))
if output, err := result.CombinedOutput(); err != nil {
t.Fatalf("apply command failed: %v: %s", err, output)
}
batch, err := os.ReadFile(batchPath)
if err != nil {
t.Fatalf("read applied batch: %v", err)
}
want := "delete table inet flvx\n" + script + "\n"
if string(batch) != want {
t.Fatalf("atomic batch = %q, want %q", batch, want)
}
}
func TestAuthMethodsDefaultToPrivateKey(t *testing.T) {
privateKey := mustGeneratePrivateKey(t)
methods, err := authMethods(SSHConfig{PrivateKey: privateKey})
@@ -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 -43
View File
@@ -16,7 +16,6 @@ type User struct {
RoleID int `gorm:"column:role_id;not null"`
ExpTime int64 `gorm:"column:exp_time;not null"`
Flow int64 `gorm:"not null"`
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
@@ -231,7 +230,6 @@ type UserTunnel struct {
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
Num int `gorm:"not null"`
Flow int64 `gorm:"not null"`
FlowMiB int64 `gorm:"column:flow_mib;not null;default:0"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
@@ -354,24 +352,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" }
@@ -420,7 +417,6 @@ type UserBackup struct {
RoleID int `json:"roleId"`
ExpTime int64 `json:"expTime"`
Flow int64 `json:"flow"`
FlowMiB int64 `json:"flowMiB,omitempty"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
@@ -527,7 +523,6 @@ type UserTunnelBackup struct {
SpeedID int64 `json:"speedId,omitempty"`
Num int `json:"num"`
Flow int64 `json:"flow"`
FlowMiB int64 `json:"flowMiB,omitempty"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
@@ -711,7 +706,6 @@ type UserTunnelDetail struct {
Status int
TunnelFlow int
Flow int64
FlowMiB int64 `gorm:"column:flow_mib"`
InFlow int64
OutFlow int64
Num int
@@ -817,23 +811,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" }
@@ -14,30 +14,15 @@ var publicConfigKeys = map[string]struct{}{
"app_logo": {},
"app_favicon": {},
"app_bg_image": {},
"app_bg_image_light": {},
"app_bg_image_dark": {},
"cloudflare_site_key": {},
"is_commercial": {},
"hide_footer_brand": {},
}
var sensitiveConfigKeys = map[string]struct{}{
"jwt_secret": {},
"license_key": {},
"license_expiry": {},
"license_machine_id": {},
"machine_fingerprint": {},
"cloudflare_secret_key": {},
}
var systemManagedConfigKeys = map[string]struct{}{
"license_key": {},
"license_expiry": {},
"license_machine_id": {},
"is_commercial": {},
"machine_fingerprint": {},
}
func PolicyForConfig(name string) ConfigAccessPolicy {
if IsPublicConfigKey(name) {
return ConfigAccessPublic
@@ -58,11 +43,6 @@ func IsSensitiveConfigKey(name string) bool {
return ok
}
func IsSystemManagedConfigKey(name string) bool {
_, ok := systemManagedConfigKeys[normalizeConfigKey(name)]
return ok
}
func FilterSensitiveConfigs(in map[string]string) map[string]string {
if len(in) == 0 {
return map[string]string{}
@@ -77,20 +57,6 @@ func FilterSensitiveConfigs(in map[string]string) map[string]string {
return out
}
func FilterBackupConfigs(in map[string]string) map[string]string {
if len(in) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(in))
for name, value := range in {
if IsSensitiveConfigKey(name) || IsSystemManagedConfigKey(name) {
continue
}
out[name] = value
}
return out
}
func normalizeConfigKey(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
@@ -12,11 +12,7 @@ func TestConfigPolicy(t *testing.T) {
{name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic},
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
{name: "app_bg_image_light is public", key: "app_bg_image_light", want: ConfigAccessPublic},
{name: "app_bg_image_dark is public", key: "app_bg_image_dark", want: ConfigAccessPublic},
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
{name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic},
{name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic},
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
@@ -33,14 +29,14 @@ func TestConfigPolicy(t *testing.T) {
}
func TestConfigPolicyHelpers(t *testing.T) {
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "app_bg_image_light", "app_bg_image_dark", "cloudflare_site_key", "is_commercial", "hide_footer_brand"}
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"}
for _, key := range publicKeys {
if !IsPublicConfigKey(key) {
t.Fatalf("expected %q to be public", key)
}
}
sensitiveKeys := []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "machine_fingerprint", "cloudflare_secret_key"}
sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"}
for _, key := range sensitiveKeys {
if !IsSensitiveConfigKey(key) {
t.Fatalf("expected %q to be sensitive", key)
@@ -71,30 +67,3 @@ func TestConfigPolicyHelpers(t *testing.T) {
t.Fatal("expected cloudflare_secret_key to be filtered out")
}
}
func TestFilterBackupConfigsOmitsLicenseState(t *testing.T) {
filtered := FilterBackupConfigs(map[string]string{
"app_name": "Brand",
"license_key": "secret-license",
"license_expiry": "never",
"license_machine_id": "machine-id",
"is_commercial": "true",
"machine_fingerprint": "fingerprint",
})
if len(filtered) != 1 || filtered["app_name"] != "Brand" {
t.Fatalf("unexpected backup configs: %+v", filtered)
}
}
func TestSystemManagedConfigKeys(t *testing.T) {
for _, key := range []string{"license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint"} {
if !IsSystemManagedConfigKey(key) {
t.Fatalf("expected %s to be system managed", key)
}
}
for _, key := range []string{"jwt_secret", "cloudflare_secret_key", "app_name"} {
if IsSystemManagedConfigKey(key) {
t.Fatalf("did not expect %s to be system managed", key)
}
}
}
@@ -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
}
+33 -54
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{},
@@ -477,14 +473,6 @@ func seedData(db *gorm.DB) {
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
now := time.Now().UnixMilli()
for name, value := range map[string]string{
"is_commercial": "false",
"hide_footer_brand": "false",
} {
cfg := model.ViteConfig{Name: name, Value: value, Time: now}
db.Where("name = ?", name).FirstOrCreate(&cfg)
}
}
// ─── User Queries ────────────────────────────────────────────────────
@@ -612,23 +600,6 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error {
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error
}
func (r *Repository) UpsertConfigs(values map[string]string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
for name, value := range values {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "time"}),
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error; err != nil {
return err
}
}
return nil
})
}
// ─── Announcement Queries ────────────────────────────────────────────
func (r *Repository) GetAnnouncement() (*model.Announcement, error) {
@@ -673,7 +644,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
}
var items []model.UserTunnelDetail
err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.flow_mib, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ?", userID).
@@ -928,7 +899,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
item := map[string]interface{}{
"id": u.ID, "user": u.User, "name": u.User,
"roleId": u.RoleID, "status": u.Status,
"flow": u.Flow, "flowMiB": u.FlowMiB, "num": u.Num, "expTime": u.ExpTime,
"flow": u.Flow, "num": u.Num, "expTime": u.ExpTime,
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
"updatedTime": nullableInt64(u.UpdatedTime),
"inFlow": u.InFlow, "outFlow": u.OutFlow,
@@ -1518,12 +1489,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 +1582,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 +1730,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) {
@@ -1986,7 +1967,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
if err != nil {
return nil, fmt.Errorf("export configs failed: %w", err)
}
backup.Configs = FilterBackupConfigs(configs)
backup.Configs = FilterSensitiveConfigs(configs)
return backup, nil
}
@@ -2066,7 +2047,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
if err != nil {
return nil, fmt.Errorf("export configs failed: %w", err)
}
backup.Configs = FilterBackupConfigs(v)
backup.Configs = FilterSensitiveConfigs(v)
}
return backup, nil
}
@@ -2088,7 +2069,7 @@ func (r *Repository) exportUsers() ([]model.UserBackup, error) {
for _, u := range users {
b := model.UserBackup{
ID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,
ExpTime: u.ExpTime, Flow: u.Flow, FlowMiB: u.FlowMiB, InFlow: u.InFlow, OutFlow: u.OutFlow,
ExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,
FlowResetTime: u.FlowResetTime, Num: u.Num,
CreatedTime: u.CreatedTime, Status: u.Status,
}
@@ -2264,7 +2245,7 @@ func (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {
for _, ut := range uts {
b := model.UserTunnelBackup{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Num: ut.Num, Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
Num: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
FlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,
}
if ut.SpeedID.Valid {
@@ -2461,7 +2442,6 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
RoleID: u.RoleID,
ExpTime: u.ExpTime,
Flow: u.Flow,
FlowMiB: u.FlowMiB,
InFlow: u.InFlow,
OutFlow: u.OutFlow,
FlowResetTime: u.FlowResetTime,
@@ -2474,7 +2454,7 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
err = tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"user", "pwd", "role_id", "exp_time", "flow", "flow_mib", "in_flow", "out_flow",
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
}),
}).Create(&item).Error
@@ -2712,7 +2692,6 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
SpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},
Num: ut.Num,
Flow: ut.Flow,
FlowMiB: ut.FlowMiB,
InFlow: ut.InFlow,
OutFlow: ut.OutFlow,
FlowResetTime: ut.FlowResetTime,
@@ -2722,7 +2701,7 @@ func importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int6
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"user_id", "tunnel_id", "speed_id", "num", "flow", "flow_mib", "in_flow", "out_flow",
"user_id", "tunnel_id", "speed_id", "num", "flow", "in_flow", "out_flow",
"flow_reset_time", "exp_time", "status",
}),
}).Create(&item).Error
@@ -2860,7 +2839,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6
}
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
configs = FilterBackupConfigs(configs)
configs = FilterSensitiveConfigs(configs)
count := 0
for name, value := range configs {
err := tx.Clauses(clause.OnConflict{
@@ -97,10 +97,6 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
seedConfig(t, r, "cloudflare_site_key", "site-key")
seedConfig(t, r, "jwt_secret", "jwt-secret")
seedConfig(t, r, "license_key", "license-secret")
seedConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
seedConfig(t, r, "license_machine_id", "machine-id")
seedConfig(t, r, "is_commercial", "true")
seedConfig(t, r, "machine_fingerprint", "machine-fingerprint")
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
for _, tc := range []struct {
@@ -121,7 +117,7 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
if backup.Configs["cloudflare_site_key"] != "site-key" {
t.Fatalf("expected public config in export, got %+v", backup.Configs)
}
for _, key := range []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint", "cloudflare_secret_key"} {
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
if _, ok := backup.Configs[key]; ok {
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
}
@@ -140,20 +136,12 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
seedConfig(t, r, "app_name", "before")
seedConfig(t, r, "jwt_secret", "jwt-before")
seedConfig(t, r, "license_key", "license-before")
seedConfig(t, r, "license_expiry", "expiry-before")
seedConfig(t, r, "license_machine_id", "machine-before")
seedConfig(t, r, "is_commercial", "true")
seedConfig(t, r, "machine_fingerprint", "fingerprint-before")
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
backup := &model.BackupData{Configs: map[string]string{
"app_name": "after",
"jwt_secret": "jwt-after",
"license_key": "license-after",
"license_expiry": "expiry-after",
"license_machine_id": "machine-after",
"is_commercial": "false",
"machine_fingerprint": "fingerprint-after",
"cloudflare_secret_key": "cloudflare-after",
}}
@@ -168,10 +156,6 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
assertConfigValue(t, r, "app_name", "after")
assertConfigValue(t, r, "jwt_secret", "jwt-before")
assertConfigValue(t, r, "license_key", "license-before")
assertConfigValue(t, r, "license_expiry", "expiry-before")
assertConfigValue(t, r, "license_machine_id", "machine-before")
assertConfigValue(t, r, "is_commercial", "true")
assertConfigValue(t, r, "machine_fingerprint", "fingerprint-before")
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
}
@@ -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
}
@@ -37,14 +37,7 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
return cnt > 0, err
}
func optionalFlowMiB(values []int64) int64 {
if len(values) > 0 {
return values[0]
}
return 0
}
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64, flowMiB ...int64) (int64, error) {
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -54,7 +47,6 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
RoleID: roleID,
ExpTime: expTime,
Flow: flow,
FlowMiB: optionalFlowMiB(flowMiB),
InFlow: 0,
OutFlow: 0,
FlowResetTime: flowResetTime,
@@ -83,7 +75,7 @@ func (r *Repository) GetUserRoleID(userID int64) (int, error) {
return user.RoleID, nil
}
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64, flowMiB ...int64) error {
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -93,7 +85,6 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
"user": username,
"pwd": pwdHash,
"flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
@@ -104,7 +95,7 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
}).Error
}
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64, flowMiB ...int64) error {
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -113,7 +104,6 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
Updates(map[string]interface{}{
"user": username,
"flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
@@ -136,7 +126,7 @@ func (r *Repository) UpdateUserPassword(userID int64, pwdHash string, now int64)
}).Error
}
func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64, flowMiB ...int64) {
func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num int, expTime, flowResetTime int64) {
if r == nil || r.db == nil {
return
}
@@ -144,7 +134,6 @@ func (r *Repository) PropagateUserFlowToTunnels(userID int64, flow int64, num in
Where("user_id = ?", userID).
Updates(map[string]interface{}{
"flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
@@ -156,9 +145,6 @@ func (r *Repository) DeleteUserCascade(userID int64) error {
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
@@ -664,7 +650,7 @@ func (r *Repository) DeleteUserTunnel(id int64) error {
return r.db.Where("id = ?", id).Delete(&model.UserTunnel{}).Error
}
func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, flowResetTime int64, speedID interface{}, status int, flowMiB ...int64) error {
func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, flowResetTime int64, speedID interface{}, status int) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -672,7 +658,6 @@ func (r *Repository) UpdateUserTunnel(id int64, flow int64, num int, expTime, fl
Where("id = ?", id).
Updates(map[string]interface{}{
"flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
@@ -707,7 +692,7 @@ func (r *Repository) GetExistingUserTunnel(userID, tunnelID int64) (id int64, fl
return ut.ID, ut.Flow, int64(ut.Num), ut.ExpTime, ut.FlowResetTime, ut.SpeedID, ut.Status, nil
}
func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{}, num int, flow, flowResetTime, expTime int64, status int, flowMiB ...int64) error {
func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{}, num int, flow, flowResetTime, expTime int64, status int) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -717,7 +702,6 @@ func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{
SpeedID: nullInt64FromInterface(speedID),
Num: num,
Flow: flow,
FlowMiB: optionalFlowMiB(flowMiB),
InFlow: 0,
OutFlow: 0,
FlowResetTime: flowResetTime,
@@ -727,7 +711,7 @@ func (r *Repository) InsertUserTunnel(userID, tunnelID int64, speedID interface{
return r.db.Create(&ut).Error
}
func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow int64, num int, expTime, flowResetTime int64, status int, flowMiB ...int64) error {
func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow int64, num int, expTime, flowResetTime int64, status int) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -736,7 +720,6 @@ func (r *Repository) UpdateUserTunnelFields(id int64, speedID interface{}, flow
Updates(map[string]interface{}{
"speed_id": nullInt64FromInterface(speedID),
"flow": flow,
"flow_mib": optionalFlowMiB(flowMiB),
"num": num,
"exp_time": expTime,
"flow_reset_time": flowResetTime,
@@ -1310,7 +1293,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return 0, false, err
}
var user model.User
if err := r.db.Select("flow, flow_mib, num, exp_time, flow_reset_time").Where("id = ?", userID).First(&user).Error; err != nil {
if err := r.db.Select("flow, num, exp_time, flow_reset_time").Where("id = ?", userID).First(&user).Error; err != nil {
return 0, false, err
}
flow := user.Flow
@@ -1322,7 +1305,6 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
TunnelID: tunnelID,
Num: num,
Flow: flow,
FlowMiB: user.FlowMiB,
InFlow: 0,
OutFlow: 0,
FlowResetTime: flowReset,
@@ -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
}
@@ -1,67 +0,0 @@
package repo
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestTrafficLimitMiBSurvivesBackupRestore(t *testing.T) {
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
if err != nil {
t.Fatal(err)
}
defer source.Close()
now := time.Now().UnixMilli()
userID, err := source.CreateUser("mib-user", "hash", 1, now+86400000, 1, 1, 10, 1, 0, now, 500)
if err != nil {
t.Fatal(err)
}
tunnel := model.Tunnel{Name: "mib-tunnel", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: now, UpdatedTime: now, Status: 1, Inx: 1}
if err := source.DB().Create(&tunnel).Error; err != nil {
t.Fatal(err)
}
if _, _, err := source.EnsureUserTunnelGrant(userID, tunnel.ID); err != nil {
t.Fatal(err)
}
grants, err := source.GetUserPackageTunnels(userID)
if err != nil || len(grants) != 1 || grants[0].FlowMiB != 500 {
t.Fatalf("inherited tunnel quota = %+v, err = %v", grants, err)
}
backup, err := source.ExportAll()
if err != nil {
t.Fatal(err)
}
found := false
for _, user := range backup.Users {
if user.User == "mib-user" {
found = user.Flow == 1 && user.FlowMiB == 500
}
}
if !found {
t.Fatal("500 MiB user quota missing from backup")
}
if len(backup.UserTunnels) != 1 || backup.UserTunnels[0].FlowMiB != 500 {
t.Fatalf("tunnel quota missing from backup: %+v", backup.UserTunnels)
}
dest, err := Open(filepath.Join(t.TempDir(), "dest.db"))
if err != nil {
t.Fatal(err)
}
defer dest.Close()
if _, err := dest.Import(backup, []string{"users", "tunnels", "userTunnels"}); err != nil {
t.Fatal(err)
}
user, err := dest.GetUserByUsername("mib-user")
if err != nil || user == nil || user.Flow != 1 || user.FlowMiB != 500 {
t.Fatalf("restored quota = %+v, err = %v", user, err)
}
grants, err = dest.GetUserPackageTunnels(user.ID)
if err != nil || len(grants) != 1 || grants[0].FlowMiB != 500 {
t.Fatalf("restored tunnel quota = %+v, err = %v", grants, err)
}
}
@@ -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{}{{
+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 {
+5 -94
View File
@@ -3,7 +3,6 @@ package config
import (
"encoding/json"
"io"
"reflect"
"sync"
"time"
@@ -23,105 +22,17 @@ 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()
return cloneConfig(global)
}
// cloneConfig returns a detached snapshot of the runtime config. Runtime
// commands mutate slices, pointers and metadata maps, so a shallow struct copy
// is not sufficient once readers and persistence run concurrently.
func cloneConfig(c *Config) *Config {
if c == nil {
return nil
}
v := cloneConfigValue(reflect.ValueOf(c))
if !v.IsValid() || v.IsNil() {
return nil
}
return v.Interface().(*Config)
}
func cloneConfigValue(v reflect.Value) reflect.Value {
if !v.IsValid() {
return reflect.Value{}
}
switch v.Kind() {
case reflect.Interface:
if v.IsNil() {
return reflect.Zero(v.Type())
}
cloned := cloneConfigValue(v.Elem())
out := reflect.New(v.Type()).Elem()
if cloned.IsValid() && cloned.Type().AssignableTo(v.Type()) {
out.Set(cloned)
} else if cloned.IsValid() && cloned.Type().Implements(v.Type()) {
out.Set(cloned)
} else if cloned.IsValid() {
out.Set(cloned)
}
return out
case reflect.Pointer:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneConfigValue(v.Elem()))
return out
case reflect.Map:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(cloneConfigValue(iter.Key()), cloneConfigValue(iter.Value()))
}
return out
case reflect.Slice:
if v.IsNil() {
return reflect.Zero(v.Type())
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneConfigValue(v.Index(i)))
}
return out
case reflect.Array:
out := reflect.New(v.Type()).Elem()
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneConfigValue(v.Index(i)))
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
if out.Field(i).CanSet() && v.Field(i).CanInterface() {
out.Field(i).Set(cloneConfigValue(v.Field(i)))
}
}
return out
default:
return v
}
cfg := &Config{}
*cfg = *global
return cfg
}
func Set(c *Config) {
-121
View File
@@ -1,121 +0,0 @@
package config
import (
"encoding/json"
"path/filepath"
"sync"
"testing"
)
func TestGlobalReturnsDetachedSnapshot(t *testing.T) {
original := Global()
t.Cleanup(func() { Set(original) })
Set(&Config{Services: []*ServiceConfig{{
Name: "snapshot-service",
Metadata: map[string]any{"paused": false},
Handler: &HandlerConfig{
Type: "relay",
Metadata: map[string]any{"retries": 2},
},
}}})
snapshot := Global()
snapshot.Services[0].Name = "changed"
snapshot.Services[0].Metadata["paused"] = true
snapshot.Services[0].Handler.Metadata["retries"] = 9
current := Global()
if current.Services[0].Name != "snapshot-service" {
t.Fatalf("snapshot mutated global service name: %q", current.Services[0].Name)
}
if paused, _ := current.Services[0].Metadata["paused"].(bool); paused {
t.Fatalf("snapshot mutated global service metadata")
}
if retries, _ := current.Services[0].Handler.Metadata["retries"].(int); retries != 2 {
t.Fatalf("snapshot mutated nested handler metadata: %v", current.Services[0].Handler.Metadata["retries"])
}
}
func TestConcurrentGlobalSnapshotAndUpdate(t *testing.T) {
original := Global()
t.Cleanup(func() { Set(original) })
Set(&Config{Services: []*ServiceConfig{{
Name: "concurrent-service",
Metadata: map[string]any{"generation": 0},
}}})
var wg sync.WaitGroup
for worker := 0; worker < 4; worker++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for i := 0; i < 500; i++ {
if err := OnUpdate(func(c *Config) error {
c.Services[0] = &ServiceConfig{
Name: "concurrent-service",
Metadata: map[string]any{"generation": worker*500 + i},
}
return nil
}); err != nil {
t.Errorf("OnUpdate: %v", err)
return
}
}
}(worker)
}
for i := 0; i < 2000; i++ {
if _, err := json.Marshal(Global()); err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
}
wg.Wait()
}
func TestConcurrentPersistProducesValidConfig(t *testing.T) {
original := Global()
originalPath := PersistPath()
persistMu.Lock()
originalEnabled := persistEnable
persistMu.Unlock()
t.Cleanup(func() {
Set(original)
SetPersistPath(originalPath)
persistMu.Lock()
persistEnable = originalEnabled
persistMu.Unlock()
})
path := filepath.Join(t.TempDir(), "gost.json")
SetPersistPath(path)
EnablePersist()
Set(&Config{Services: []*ServiceConfig{{Name: "persist-service"}}})
var wg sync.WaitGroup
for worker := 0; worker < 4; worker++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for i := 0; i < 50; i++ {
if err := OnUpdate(func(c *Config) error {
c.Services[0].Metadata = map[string]any{"generation": worker*50 + i}
return nil
}); err != nil {
t.Errorf("persist update: %v", err)
return
}
}
}(worker)
}
wg.Wait()
var persisted Config
if err := persisted.ReadFile(path); err != nil {
t.Fatalf("read persisted config: %v", err)
}
if len(persisted.Services) != 1 || persisted.Services[0] == nil || persisted.Services[0].Name != "persist-service" {
t.Fatalf("unexpected persisted services: %#v", persisted.Services)
}
}
-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
@@ -42,9 +42,9 @@ func EnablePersist() {
// persist writes the current global config to the configured file atomically.
func persist() error {
persistMu.Lock()
defer persistMu.Unlock()
path := persistPath
enabled := persistEnable
persistMu.Unlock()
if !enabled || path == "" {
return nil
+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)
}
}
-153
View File
@@ -1,153 +0,0 @@
package socket
import (
"net"
"sync"
"testing"
"time"
coreservice "github.com/go-gost/core/service"
"github.com/go-gost/x/config"
"github.com/go-gost/x/registry"
)
type blockingCommandService struct {
started chan struct{}
release chan struct{}
startedOnce sync.Once
}
func (s *blockingCommandService) Serve() error { return nil }
func (s *blockingCommandService) Addr() net.Addr { return nil }
func (s *blockingCommandService) Close() error {
s.startedOnce.Do(func() { close(s.started) })
<-s.release
return nil
}
func TestMutationCommandsAreSerialized(t *testing.T) {
mutations := []string{
"AddService", "UpdateService", "DeleteService", "PauseService", "ResumeService",
"AddChains", "UpdateChains", "DeleteChains",
"AddLimiters", "UpdateLimiters", "DeleteLimiters",
"AddCLimiters", "UpdateCLimiters", "DeleteCLimiters",
"SetProtocol", "UpgradeAgent", "RollbackAgent", "reload",
}
for _, command := range mutations {
if !isMutationCommand(command) {
t.Fatalf("expected %s to use the serialized mutation queue", command)
}
}
}
func TestReadOnlyCommandsRemainBoundedAsync(t *testing.T) {
for _, command := range []string{"TcpPing", "UdpPing", "ServiceMonitorCheck"} {
if isMutationCommand(command) {
t.Fatalf("expected %s to remain a read-only command", command)
}
}
}
func TestCommandResponseTypePreservesRequestContract(t *testing.T) {
if got := commandResponseType("UpdateService"); got != "UpdateServiceResponse" {
t.Fatalf("unexpected response type: %s", got)
}
if got := commandResponseType(""); got != "UnknownCommandResponse" {
t.Fatalf("unexpected empty command response type: %s", got)
}
}
func TestMutationQueueExecutesCommandsInArrivalOrder(t *testing.T) {
originalConfig := config.Global()
t.Cleanup(func() { config.Set(originalConfig) })
firstName := "mutation_queue_first_tdd"
secondName := "mutation_queue_second_tdd"
first := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
second := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
for _, name := range []string{firstName, secondName} {
registry.ServiceRegistry().Unregister(name)
}
t.Cleanup(func() {
select {
case <-first.release:
default:
close(first.release)
}
select {
case <-second.release:
default:
close(second.release)
}
registry.ServiceRegistry().Unregister(firstName)
registry.ServiceRegistry().Unregister(secondName)
})
if err := registry.ServiceRegistry().Register(firstName, coreservice.Service(first)); err != nil {
t.Fatalf("register first service: %v", err)
}
if err := registry.ServiceRegistry().Register(secondName, coreservice.Service(second)); err != nil {
t.Fatalf("register second service: %v", err)
}
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: firstName}, {Name: secondName}}})
reporter := NewWebSocketReporter("", "mutation-queue-test-secret")
go reporter.runMutationCommands()
t.Cleanup(reporter.Stop)
reporter.dispatchCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{firstName}}})
reporter.dispatchCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{secondName}}})
select {
case <-first.started:
case <-time.After(time.Second):
t.Fatal("first mutation did not start")
}
select {
case <-second.started:
t.Fatal("second mutation started before first mutation completed")
case <-time.After(100 * time.Millisecond):
}
close(first.release)
select {
case <-second.started:
case <-time.After(time.Second):
t.Fatal("second mutation did not start after first mutation completed")
}
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")
}
}
+18 -82
View File
@@ -15,11 +15,6 @@ import (
xservice "github.com/go-gost/x/service"
)
type serviceReplacement struct {
config config.ServiceConfig
oldConfig *config.ServiceConfig
}
func createServices(req createServicesRequest) error {
if len(req.Data) == 0 {
@@ -100,11 +95,11 @@ func updateServices(req updateServicesRequest) error {
req.Data[i].Name = name
}
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)。
// 配置变更命令由 WebSocket reporter 串行调度,但这里仍保留完整回滚,
// 避免新配置解析或监听失败后旧服务永久消失。
originalConfig := config.Global()
changedServices := make([]serviceReplacement, 0, len(req.Data))
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)
changedServices := make([]struct {
config config.ServiceConfig
service service.Service
}, 0, len(req.Data))
for i := range req.Data {
serviceConfig := &req.Data[i]
name := serviceConfig.Name
@@ -112,48 +107,33 @@ func updateServices(req updateServicesRequest) error {
continue
}
var oldConfig *config.ServiceConfig
if originalConfig != nil {
for _, current := range originalConfig.Services {
if current != nil && strings.TrimSpace(current.Name) == name {
oldConfig = current
break
}
}
}
// 1. 获取旧服务
old := registry.ServiceRegistry().Get(name)
// 1. 关闭并移除旧服务(如果存在)。同名监听必须先释放端口,
// 才能创建新 listener。
if registry.ServiceRegistry().Get(name) != nil {
// 2. 关闭旧服务 (如果存在)
if old != nil {
// 3. 从注册表移除旧服务;registry 会负责关闭旧服务。
registry.ServiceRegistry().Unregister(name)
}
// 2. 解析新服务配置。
// 4. 解析新服务配置
svc, err := parser.ParseService(serviceConfig)
if err != nil {
rollbackErr := restoreServiceRuntime(name, oldConfig)
rollbackErr = errors.Join(rollbackErr, rollbackServiceReplacements(changedServices))
if rollbackErr != nil {
return fmt.Errorf("create service %s failed: %v; restore previous service failed: %w", name, err, rollbackErr)
}
return errors.New("create service " + name + " failed: " + err.Error())
}
changedServices = append(changedServices, struct {
config config.ServiceConfig
service service.Service
}{*serviceConfig, svc})
// 3. 注册并启动新服务。
// 5. 注册新服务
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
svc.Close()
rollbackErr := restoreServiceRuntime(name, oldConfig)
rollbackErr = errors.Join(rollbackErr, rollbackServiceReplacements(changedServices))
if rollbackErr != nil {
return fmt.Errorf("service %s already exists; restore previous service failed: %w", name, rollbackErr)
}
return errors.New("service " + name + " already exists")
}
// 6. 启动新服务
go svc.Serve()
changedServices = append(changedServices, serviceReplacement{
config: *serviceConfig,
oldConfig: oldConfig,
})
}
if len(changedServices) == 0 {
return nil
@@ -178,56 +158,12 @@ func updateServices(req updateServicesRequest) error {
}
return nil
}); err != nil {
config.Set(originalConfig)
if rollbackErr := rollbackServiceReplacements(changedServices); rollbackErr != nil {
return fmt.Errorf("%w; restore previous services failed: %v", err, rollbackErr)
}
return err
}
return nil
}
func restoreServiceRuntime(name string, serviceConfig *config.ServiceConfig) error {
name = strings.TrimSpace(name)
if name == "" || serviceConfig == nil {
return nil
}
if registry.ServiceRegistry().Get(name) != nil {
registry.ServiceRegistry().Unregister(name)
}
cfgCopy := *serviceConfig
cfgCopy.Name = name
svc, err := parser.ParseService(&cfgCopy)
if err != nil {
return err
}
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
svc.Close()
return err
}
go svc.Serve()
return nil
}
func rollbackServiceReplacements(replacements []serviceReplacement) error {
var rollbackErr error
for i := len(replacements) - 1; i >= 0; i-- {
name := strings.TrimSpace(replacements[i].config.Name)
if name == "" {
continue
}
if registry.ServiceRegistry().Get(name) != nil {
registry.ServiceRegistry().Unregister(name)
}
if err := restoreServiceRuntime(name, replacements[i].oldConfig); err != nil {
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore service %s: %w", name, err))
}
}
return rollbackErr
}
func serviceConfigUnchanged(name string, next config.ServiceConfig) bool {
cfg := config.Global()
if cfg == nil {
-39
View File
@@ -7,8 +7,6 @@ import (
corelogger "github.com/go-gost/core/logger"
"github.com/go-gost/core/service"
"github.com/go-gost/x/config"
_ "github.com/go-gost/x/handler/auto"
_ "github.com/go-gost/x/listener/tcp"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
)
@@ -17,43 +15,6 @@ type recordingService struct {
closed int
}
func TestUpdateServicesParseFailureRestoresPreviousRuntime(t *testing.T) {
corelogger.SetDefault(xlogger.Nop())
name := "restore_after_failed_update_tdd"
existing := &recordingService{}
registry.ServiceRegistry().Unregister(name)
t.Cleanup(func() { registry.ServiceRegistry().Unregister(name) })
if err := registry.ServiceRegistry().Register(name, service.Service(existing)); err != nil {
t.Fatalf("register existing service: %v", err)
}
originalConfig := config.Global()
t.Cleanup(func() { config.Set(originalConfig) })
serviceConfig := config.ServiceConfig{Name: name, Addr: "127.0.0.1:0"}
config.Set(&config.Config{Services: []*config.ServiceConfig{&serviceConfig}})
invalid := serviceConfig
invalid.Listener = &config.ListenerConfig{Type: "listener-does-not-exist"}
if err := updateServices(updateServicesRequest{Data: []config.ServiceConfig{invalid}}); err == nil {
t.Fatalf("expected invalid service update to fail")
}
if existing.closed != 1 {
t.Fatalf("expected old runtime to be closed once, got %d", existing.closed)
}
if registry.ServiceRegistry().Get(name) == nil {
t.Fatalf("expected previous runtime to be restored")
}
cfg := config.Global()
if len(cfg.Services) != 1 || cfg.Services[0] == nil || cfg.Services[0].Name != name {
t.Fatalf("expected previous config to remain, got %#v", cfg.Services)
}
if cfg.Services[0].Listener != nil {
t.Fatalf("expected previous listener config to remain unchanged")
}
}
func (s *recordingService) Serve() error { return nil }
func (s *recordingService) Addr() net.Addr { return nil }
func (s *recordingService) Close() error {
+8 -115
View File
@@ -16,7 +16,6 @@ import (
"os"
"os/exec"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync"
@@ -153,8 +152,6 @@ const (
maxBackoff = 2 * time.Minute // 重连最大退避
defaultMetricReportInterval = 5 * time.Second
maxConcurrentTCPPings = 8
maxConcurrentReadCommands = 16
maxQueuedMutationCommands = 256
)
type WebSocketReporter struct {
@@ -177,9 +174,6 @@ type WebSocketReporter struct {
connMutex sync.Mutex // 连接状态锁
aesCrypto *crypto.AESCrypto // AES加密器
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) {
@@ -210,8 +204,6 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
connecting: false,
aesCrypto: aesCrypto,
tcpPingSem: make(chan struct{}, maxConcurrentTCPPings),
readCommandSem: make(chan struct{}, maxConcurrentReadCommands),
mutationQueue: make(chan CommandMessage, maxQueuedMutationCommands),
}
}
@@ -239,15 +231,7 @@ 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.run()
}
// Stop 停止WebSocket报告器
@@ -258,7 +242,6 @@ func (w *WebSocketReporter) Stop() {
w.conn.Close()
}
w.connMutex.Unlock()
w.workers.Wait()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
@@ -348,10 +331,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 +520,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)
@@ -798,7 +777,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
w.dispatchCommand(cmdMsg)
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
} else {
// 处理普通消息
@@ -809,7 +789,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
w.dispatchCommand(cmdMsg)
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
}
@@ -818,96 +799,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
}
// dispatchCommand keeps all runtime mutations ordered while allowing bounded
// concurrency for read-only diagnostics. Mutations share process-wide
// registries and configuration, so running them concurrently can corrupt the
// persisted config or interleave service lifecycle operations.
func (w *WebSocketReporter) dispatchCommand(cmd CommandMessage) {
if isMutationCommand(cmd.Type) {
select {
case w.mutationQueue <- cmd:
case <-w.ctx.Done():
w.sendCommandFailure(cmd, "Agent is shutting down")
default:
w.sendCommandFailure(cmd, "运行时配置命令队列已满,请稍后重试")
}
return
}
select {
case w.readCommandSem <- struct{}{}:
go func() {
defer func() { <-w.readCommandSem }()
w.routeCommandSafely(cmd)
}()
case <-w.ctx.Done():
w.sendCommandFailure(cmd, "Agent is shutting down")
default:
w.sendCommandFailure(cmd, "只读命令并发过多,请稍后重试")
}
}
func (w *WebSocketReporter) runMutationCommands() {
for {
select {
case <-w.ctx.Done():
return
case cmd := <-w.mutationQueue:
w.routeCommandSafely(cmd)
}
}
}
func (w *WebSocketReporter) routeCommandSafely(cmd CommandMessage) {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Printf("❌ 命令处理 panic: type=%s panic=%v\n%s", cmd.Type, recovered, debug.Stack())
w.sendCommandFailure(cmd, fmt.Sprintf("命令处理异常: %v", recovered))
}
}()
w.routeCommand(cmd)
}
func (w *WebSocketReporter) sendCommandFailure(cmd CommandMessage, message string) {
w.sendResponse(CommandResponse{
Type: commandResponseType(cmd.Type),
Success: false,
Message: message,
RequestId: cmd.RequestId,
})
}
func commandResponseType(commandType string) string {
commandType = strings.TrimSpace(commandType)
if commandType == "" {
return "UnknownCommandResponse"
}
return commandType + "Response"
}
func isMutationCommand(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice",
"addchains", "updatechains", "deletechains",
"addlimiters", "updatelimiters", "deletelimiters",
"addclimiters", "updateclimiters", "deleteclimiters",
"setprotocol", "upgradeagent", "rollbackagent", "reload":
return true
default:
return false
}
}
// 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)
+5 -46
View File
@@ -64,38 +64,6 @@ SERVICE_MANAGER="${SERVICE_MANAGER:-}"
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
ensure_alpine_runtime_dependencies() {
[[ -f /etc/alpine-release ]] || return 0
local missing_packages=()
local privileged_command=""
command -v curl >/dev/null 2>&1 || missing_packages+=(curl)
[[ -f /etc/ssl/certs/ca-certificates.crt ]] || missing_packages+=(ca-certificates)
if [[ ${#missing_packages[@]} -eq 0 ]]; then
return 0
fi
if [[ $EUID -ne 0 ]]; then
if command -v sudo >/dev/null 2>&1; then
privileged_command="sudo"
elif command -v doas >/dev/null 2>&1; then
privileged_command="doas"
else
echo "❌ Alpine 安装需要 root 权限,或已配置 sudo/doas 来安装依赖: ${missing_packages[*]}。" >&2
return 1
fi
fi
echo "📦 Alpine 缺少运行依赖,正在安装: ${missing_packages[*]}"
if [[ -n "$privileged_command" ]]; then
"$privileged_command" apk add --no-cache "${missing_packages[@]}"
else
apk add --no-cache "${missing_packages[@]}"
fi
}
# 镜像加速
maybe_proxy_url() {
local url="$1"
@@ -201,8 +169,6 @@ build_download_url() {
}
ensure_download_url_initialized() {
ensure_alpine_runtime_dependencies || return 1
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
return 0
fi
@@ -333,16 +299,11 @@ ensure_service_manager() {
esac
fi
# Alpine uses OpenRC even if a systemctl compatibility command happens to be installed.
if [[ -f /etc/alpine-release ]]; then
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
SERVICE_MANAGER="openrc"
return 0
fi
elif command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
SERVICE_MANAGER="systemd"
return 0
elif command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
fi
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
SERVICE_MANAGER="openrc"
return 0
fi
@@ -405,7 +366,6 @@ After=network.target
[Service]
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/flux_agent
Environment=GODEBUG=disablethp=1
Restart=on-failure
StandardOutput=null
StandardError=null
@@ -541,8 +501,7 @@ cleanup_legacy_gost_installation() {
return 0
fi
if [[ "$SERVICE_MANAGER" == "systemd" ]] && \
systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
systemctl stop gost 2>/dev/null || true
systemctl disable gost 2>/dev/null || true
fi
@@ -561,7 +520,7 @@ cleanup_legacy_gost_installation() {
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
fi
if [[ "$removed_service_file" == "1" && "$SERVICE_MANAGER" == "systemd" ]]; then
if [[ "$removed_service_file" == "1" ]]; then
systemctl daemon-reload 2>/dev/null || true
fi
}
+48 -319
View File
@@ -16,9 +16,6 @@ PINNED_VERSION=""
# 镜像加速配置(可由面板传入或交互式询问)
PROXY_ENABLED="${PROXY_ENABLED:-}"
PROXY_URL="${PROXY_URL:-}"
DEFAULT_PANEL_BACKEND_CONTAINER="flux-panel-backend"
DEFAULT_PANEL_POSTGRES_CONTAINER="flux-panel-postgres"
PANEL_SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
# 镜像加速
maybe_proxy_url() {
@@ -307,8 +304,8 @@ get_env_var() {
get_current_db_type() {
local db_type database_url
db_type=$(get_env_var "DB_TYPE" || true)
database_url=$(get_env_var "DATABASE_URL" || true)
db_type=$(get_env_var "DB_TYPE")
database_url=$(get_env_var "DATABASE_URL")
if [[ "$db_type" == "sqlite" ]]; then
echo "sqlite"
@@ -319,289 +316,13 @@ get_current_db_type() {
fi
}
get_container_compose_label() {
local container="$1"
local label="$2"
local value
value=$(docker inspect -f "{{ index .Config.Labels \"$label\" }}" "$container" 2>/dev/null || true)
if [[ "$value" == "<no value>" ]]; then
value=""
fi
printf '%s' "$value"
}
resolve_panel_deployment() {
local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}"
local requested_dir="${PANEL_DEPLOY_DIR:-}"
local label_dir label_project deploy_dir project_name
label_dir=$(get_container_compose_label "$backend_container" "com.docker.compose.project.working_dir")
label_project=$(get_container_compose_label "$backend_container" "com.docker.compose.project")
if [[ -n "$requested_dir" ]]; then
deploy_dir="$requested_dir"
elif [[ -n "$label_dir" ]]; then
deploy_dir="$label_dir"
elif [[ -f ".env" && -f "docker-compose.yml" ]]; then
deploy_dir=$(pwd -P)
else
echo "❌ 无法识别面板部署目录:未找到容器 Compose 标签,当前目录也没有完整部署配置"
return 1
fi
if [[ ! -d "$deploy_dir" ]]; then
echo "❌ 面板部署目录不存在:$deploy_dir"
return 1
fi
deploy_dir=$(cd "$deploy_dir" && pwd -P)
if [[ -n "$label_dir" && -d "$label_dir" ]]; then
label_dir=$(cd "$label_dir" && pwd -P)
fi
if [[ -n "$requested_dir" && -n "$label_dir" && "$deploy_dir" != "$label_dir" ]]; then
echo "❌ 指定部署目录与运行中容器标签不一致:$deploy_dir != $label_dir"
return 1
fi
project_name="${COMPOSE_PROJECT_NAME:-}"
if [[ -z "$project_name" && -n "$label_project" ]]; then
project_name="$label_project"
fi
if [[ -n "$project_name" && ! "$project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "❌ Compose 项目名不合法:$project_name"
return 1
fi
PANEL_DEPLOY_DIR="$deploy_dir"
PANEL_BACKEND_CONTAINER="$backend_container"
PANEL_POSTGRES_CONTAINER="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}"
PANEL_COMPOSE_PROJECT="$project_name"
export PANEL_DEPLOY_DIR PANEL_BACKEND_CONTAINER PANEL_POSTGRES_CONTAINER PANEL_COMPOSE_PROJECT
if [[ -n "$project_name" ]]; then
COMPOSE_PROJECT_NAME="$project_name"
export COMPOSE_PROJECT_NAME
fi
cd "$PANEL_DEPLOY_DIR"
echo "📁 部署目录:$PANEL_DEPLOY_DIR"
if [[ -n "$PANEL_COMPOSE_PROJECT" ]]; then
echo "📦 Compose 项目:$PANEL_COMPOSE_PROJECT"
fi
}
run_panel_compose() {
$DOCKER_CMD "$@"
}
validate_panel_update_environment() {
local key value backend_port frontend_port configured_db_type db_type database_url postgres_password
if [[ ! -f ".env" ]]; then
echo "❌ 部署目录缺少 .env,更新终止"
return 1
fi
if [[ ! -f "docker-compose.yml" ]]; then
echo "❌ 部署目录缺少 docker-compose.yml,更新终止"
return 1
fi
for key in JWT_SECRET BACKEND_PORT FRONTEND_PORT; do
value=$(get_env_var "$key" || true)
if [[ -z "${value//[[:space:]]/}" ]]; then
echo "❌ .env 中 $key 缺失或为空,更新终止"
return 1
fi
done
backend_port=$(get_env_var "BACKEND_PORT" || true)
frontend_port=$(get_env_var "FRONTEND_PORT" || true)
if [[ ! "$backend_port" =~ ^[0-9]+$ || "$backend_port" -lt 1 || "$backend_port" -gt 65535 ]]; then
echo "❌ BACKEND_PORT 不是有效端口:$backend_port"
return 1
fi
if [[ ! "$frontend_port" =~ ^[0-9]+$ || "$frontend_port" -lt 1 || "$frontend_port" -gt 65535 ]]; then
echo "❌ FRONTEND_PORT 不是有效端口:$frontend_port"
return 1
fi
configured_db_type=$(get_env_var "DB_TYPE" || true)
if [[ -n "$configured_db_type" && "$configured_db_type" != "sqlite" && "$configured_db_type" != "postgres" ]]; then
echo "❌ DB_TYPE 仅支持 sqlite 或 postgres:$configured_db_type"
return 1
fi
db_type=$(get_current_db_type)
if [[ "$db_type" == "postgres" ]]; then
database_url=$(get_env_var "DATABASE_URL" || true)
postgres_password=$(get_env_var "POSTGRES_PASSWORD" || true)
if [[ -z "${database_url//[[:space:]]/}" || -z "${postgres_password//[[:space:]]/}" ]]; then
echo "❌ PostgreSQL 模式要求 DATABASE_URL 和 POSTGRES_PASSWORD 均非空"
return 1
fi
fi
if ! run_panel_compose -f docker-compose.yml config -q; then
echo "❌ 当前 Compose 配置校验失败,更新终止"
return 1
fi
}
download_panel_update_compose() {
local compose_url="$1"
UPDATE_COMPOSE_CANDIDATE=$(mktemp "$PANEL_DEPLOY_DIR/.docker-compose.yml.update.XXXXXX")
if ! curl -fL -o "$UPDATE_COMPOSE_CANDIDATE" "$compose_url"; then
rm -f "$UPDATE_COMPOSE_CANDIDATE"
UPDATE_COMPOSE_CANDIDATE=""
echo "❌ 下载最新 Compose 配置失败,更新终止"
return 1
fi
if [[ ! -s "$UPDATE_COMPOSE_CANDIDATE" ]]; then
rm -f "$UPDATE_COMPOSE_CANDIDATE"
UPDATE_COMPOSE_CANDIDATE=""
echo "❌ 下载的 Compose 配置为空,更新终止"
return 1
fi
}
validate_panel_update_compose() {
if ! FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" config -q; then
echo "❌ 新 Compose 配置校验失败,更新终止"
return 1
fi
}
backup_sqlite_for_update() (
local destination="$1"
local running paused="false"
cleanup_paused_backend() {
if [[ "$paused" == "true" ]]; then
docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null 2>&1 || true
fi
}
trap cleanup_paused_backend EXIT
running=$(docker inspect -f '{{.State.Running}}' "$PANEL_BACKEND_CONTAINER" 2>/dev/null || true)
if [[ "$running" == "true" ]]; then
if ! docker pause "$PANEL_BACKEND_CONTAINER" >/dev/null; then
echo "❌ 暂停后端以创建一致性 SQLite 备份失败"
return 1
fi
paused="true"
fi
mkdir -p "$destination"
if ! docker cp "$PANEL_BACKEND_CONTAINER:/app/data/." "$destination"; then
echo "❌ SQLite 数据备份失败"
return 1
fi
if [[ "$paused" == "true" ]] && ! docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null; then
echo "❌ SQLite 备份完成,但恢复后端运行失败"
return 1
fi
paused="false"
)
backup_postgres_for_update() {
local destination="$1"
local postgres_db postgres_user
postgres_db=$(get_env_var "POSTGRES_DB" || true)
postgres_user=$(get_env_var "POSTGRES_USER" || true)
postgres_db=${postgres_db:-flux_panel}
postgres_user=${postgres_user:-flux_panel}
if ! docker exec "$PANEL_POSTGRES_CONTAINER" pg_dump -U "$postgres_user" "$postgres_db" > "$destination"; then
rm -f "$destination"
echo "❌ PostgreSQL 数据备份失败"
return 1
fi
}
create_panel_update_backup() {
local db_type="$1"
local timestamp
timestamp=$(date '+%Y%m%d-%H%M%S')
if ! (umask 077 && mkdir -p "$PANEL_DEPLOY_DIR/backups"); then
echo "❌ 创建更新备份目录失败"
return 1
fi
UPDATE_BACKUP_DIR=$(umask 077 && mktemp -d "$PANEL_DEPLOY_DIR/backups/panel-update-$timestamp.XXXXXX") || {
echo "❌ 创建更新备份目录失败"
return 1
}
if ! cp -p .env docker-compose.yml "$UPDATE_BACKUP_DIR/"; then
echo "❌ 备份面板配置失败"
return 1
fi
if [[ "$db_type" == "postgres" ]]; then
backup_postgres_for_update "$UPDATE_BACKUP_DIR/postgres.sql" || return 1
else
backup_sqlite_for_update "$UPDATE_BACKUP_DIR/sqlite" || return 1
fi
echo "💾 更新备份:$UPDATE_BACKUP_DIR"
}
pull_panel_update_images() {
local db_type="$1"
if [[ "$db_type" == "postgres" ]]; then
FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend postgres
else
FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend
fi
}
activate_panel_update_files() {
if ! chmod 0644 "$UPDATE_COMPOSE_CANDIDATE" || ! mv "$UPDATE_COMPOSE_CANDIDATE" docker-compose.yml; then
echo "❌ 替换 Compose 配置失败"
return 1
fi
UPDATE_COMPOSE_CANDIDATE=""
if ! upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"; then
echo "❌ 更新版本配置失败"
return 1
fi
}
start_panel_after_update() {
local db_type="$1"
if [[ "$db_type" == "postgres" ]]; then
run_panel_compose up -d postgres || return 1
wait_for_postgres_healthy || return 1
fi
run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1
wait_for_backend_healthy
}
rollback_panel_update() {
local db_type="$1"
echo "↩️ 正在恢复更新前配置..."
if ! cp -p "$UPDATE_BACKUP_DIR/.env" .env || ! cp -p "$UPDATE_BACKUP_DIR/docker-compose.yml" docker-compose.yml; then
echo "❌ 配置回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复"
return 1
fi
if [[ "$db_type" == "postgres" ]]; then
run_panel_compose up -d postgres || return 1
wait_for_postgres_healthy || return 1
fi
run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1
wait_for_backend_healthy
}
wait_for_postgres_healthy() {
local pg_health
local postgres_container="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}"
echo "🔍 检查 PostgreSQL 服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -Fxq "$postgres_container"; then
pg_health=$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo "unknown")
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then
pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown")
if [[ "$pg_health" == "healthy" ]]; then
echo "✅ PostgreSQL 服务健康检查通过"
return 0
@@ -614,7 +335,7 @@ wait_for_postgres_healthy() {
if [ $i -eq 90 ]; then
echo "❌ PostgreSQL 启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo '容器不存在')"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')"
return 1
fi
@@ -627,12 +348,11 @@ wait_for_postgres_healthy() {
wait_for_backend_healthy() {
local backend_health
local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}"
echo "🔍 检查后端服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -Fxq "$backend_container"; then
backend_health=$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo "unknown")
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
if [[ "$backend_health" == "healthy" ]]; then
echo "✅ 后端服务健康检查通过"
return 0
@@ -645,7 +365,7 @@ wait_for_backend_healthy() {
if [ $i -eq 90 ]; then
echo "❌ 后端服务启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo '容器不存在')"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
return 1
fi
@@ -660,8 +380,9 @@ wait_for_backend_healthy() {
delete_self() {
echo ""
echo "🗑️ 操作已完成,正在清理脚本文件..."
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
sleep 1
rm -f "$PANEL_SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
rm -f "$SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
}
@@ -765,11 +486,12 @@ EOF
# 更新功能
update_panel() {
echo "🔄 开始更新面板..."
check_docker
resolve_panel_deployment || return 1
ask_proxy_config
validate_panel_update_environment || return 1
check_docker
if [[ ! -f ".env" ]]; then
echo "⚠️ 未找到 .env,默认按 SQLite 模式更新"
fi
CURRENT_DB_TYPE=$(get_current_db_type)
echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE"
@@ -780,45 +502,52 @@ update_panel() {
}
echo "🆕 最新版本:$LATEST_VERSION"
set_compose_urls_by_version "$LATEST_VERSION"
upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"
echo "🔽 下载最新配置文件..."
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
download_panel_update_compose "$DOCKER_COMPOSE_URL" || return 1
if ! validate_panel_update_compose; then
rm -f "$UPDATE_COMPOSE_CANDIDATE"
UPDATE_COMPOSE_CANDIDATE=""
return 1
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
echo "✅ 下载完成"
# 自动检测并配置 IPv6 支持
if check_ipv6_support; then
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
configure_docker_ipv6
fi
echo "💾 备份当前配置和数据库..."
if ! create_panel_update_backup "$CURRENT_DB_TYPE"; then
rm -f "$UPDATE_COMPOSE_CANDIDATE"
UPDATE_COMPOSE_CANDIDATE=""
return 1
fi
# 先发送 SIGTERM 信号,让应用优雅关闭
docker stop -t 30 flux-panel-backend 2>/dev/null || true
docker stop -t 10 vite-frontend 2>/dev/null || true
# 等待 WAL 文件同步
echo "⏳ 等待数据同步..."
sleep 5
# 然后再完全停止
$DOCKER_CMD down
echo "⬇️ 拉取最新镜像..."
if ! pull_panel_update_images "$CURRENT_DB_TYPE"; then
rm -f "$UPDATE_COMPOSE_CANDIDATE"
UPDATE_COMPOSE_CANDIDATE=""
echo "❌ 镜像拉取失败,现有服务保持运行"
return 1
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
$DOCKER_CMD pull backend frontend postgres
else
$DOCKER_CMD pull backend frontend
fi
if ! activate_panel_update_files; then
rollback_panel_update "$CURRENT_DB_TYPE" || true
return 1
echo "🚀 启动更新后的服务..."
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
$DOCKER_CMD up -d postgres
wait_for_postgres_healthy
$DOCKER_CMD up -d backend frontend
else
$DOCKER_CMD up -d backend frontend
fi
echo "🚀 原地重建更新后的服务..."
if ! start_panel_after_update "$CURRENT_DB_TYPE"; then
echo "🛑 新版本启动失败"
if rollback_panel_update "$CURRENT_DB_TYPE"; then
echo "✅ 已恢复更新前版本"
else
echo "❌ 自动回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复"
fi
# 等待服务启动
echo "⏳ 等待服务启动..."
if ! wait_for_backend_healthy; then
echo "🛑 更新终止"
return 1
fi
-86
View File
@@ -1,86 +0,0 @@
#!/bin/sh
set -eu
# Run only in a disposable Alpine container: this exercises real OpenRC services.
# docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
[ -f /etc/alpine-release ] && [ "$(id -u)" = 0 ] || {
echo "Run this test as root in a disposable Alpine container." >&2
exit 1
}
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
INSTALL_SCRIPT=${1:-"$ROOT_DIR/install.sh"}
TEST_DIR=$(mktemp -d)
export TEST_DIR
cleanup() {
rc-service flux_agent stop >/dev/null 2>&1 || true
rc-update del flux_agent default >/dev/null 2>&1 || true
rm -f /etc/init.d/flux_agent
rm -rf "$TEST_DIR"
}
trap cleanup EXIT
# Docker supplies the network; an empty interfaces file lets OpenRC register
# that dependency without changing the container's network configuration.
apk add --no-cache openrc
mkdir -p /run/openrc /etc/network
touch /run/openrc/softlevel /etc/network/interfaces
rc-service networking start
# Fail immediately if any path accidentally calls systemd on Alpine.
mkdir -p "$TEST_DIR/bin"
cat > "$TEST_DIR/bin/systemctl" <<'EOF'
#!/bin/sh
touch "$TEST_DIR/systemctl-called"
exit 1
EOF
chmod +x "$TEST_DIR/bin/systemctl"
export PATH="$TEST_DIR/bin:$PATH"
cat > "$TEST_DIR/agent" <<'EOF'
#!/bin/sh
if [ "${1:-}" = -V ]; then
echo "Alpine installer test agent"
exit 0
fi
pwd > ../working-directory
exec sleep 300
EOF
# Keep the installer bootstrap, service detection and all lifecycle operations.
# Substitute only the network download and optional tcpkill dependency.
sed '/^# 执行主函数$/,$d' "$INSTALL_SCRIPT" > "$TEST_DIR/install.sh"
cat >> "$TEST_DIR/install.sh" <<'EOF'
INSTALL_DIR="$TEST_DIR/flux_agent"
ensure_download_url_initialized() {
ensure_alpine_runtime_dependencies || return 1
DOWNLOAD_URL="file://$TEST_DIR/agent"
}
check_and_install_tcpkill() { :; }
main
EOF
chmod +x "$TEST_DIR/install.sh"
cp "$TEST_DIR/install.sh" "$TEST_DIR/manage.sh"
PROXY_ENABLED=false "$TEST_DIR/install.sh" -a http://127.0.0.1:9 -s alpine-test
rc-service flux_agent status
test -L /etc/runlevels/default/flux_agent
test "$(cat "$TEST_DIR/working-directory")" = "$TEST_DIR/flux_agent"
test -s "$TEST_DIR/flux_agent/config.json"
rc-service flux_agent restart
rc-service flux_agent status
# Update must retain config and restart through OpenRC.
cp "$TEST_DIR/flux_agent/config.json" "$TEST_DIR/config-before-update.json"
cp "$TEST_DIR/manage.sh" "$TEST_DIR/update.sh"
printf '2\n' | PROXY_ENABLED=false "$TEST_DIR/update.sh"
cmp "$TEST_DIR/config-before-update.json" "$TEST_DIR/flux_agent/config.json"
rc-service flux_agent status
printf '3\ny\n' | "$TEST_DIR/manage.sh"
test ! -e /etc/init.d/flux_agent
test ! -e /etc/runlevels/default/flux_agent
test ! -e "$TEST_DIR/flux_agent"
test ! -e "$TEST_DIR/systemctl-called"
echo "Alpine installer lifecycle tests passed"
+6 -142
View File
@@ -277,8 +277,6 @@ EOF
local expected=$'{\n "addr": "panel\\"addr",\n "secret": "sec\\\\ret\\"1"\n}'
assert_equals "$expected" "$actual" "install_flux_agent should JSON-escape config values"
grep -Fqx 'Environment=GODEBUG=disablethp=1' "$FLUX_AGENT_SYSTEMD_SERVICE_FILE" || \
fail "systemd service should disable transparent huge pages for the Go heap"
)
test_install_script_bootstraps_bash_for_alpine() (
@@ -401,7 +399,6 @@ test_cleanup_legacy_gost_installation_removes_service_and_binary() (
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
SERVICE_MANAGER="systemd"
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
[Unit]
Description=Gost Proxy Service
@@ -445,7 +442,6 @@ test_cleanup_legacy_gost_installation_preserves_unrelated_gost() (
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
SERVICE_MANAGER="systemd"
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
[Unit]
Description=Unrelated Gost Service
@@ -473,35 +469,6 @@ EOF
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
)
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
SERVICE_MANAGER="openrc"
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
[Unit]
WorkingDirectory=$LEGACY_GOST_CONFIG_DIR
ExecStart=$LEGACY_GOST_CONFIG_DIR/gost
EOF
: > "$LEGACY_GOST_CONFIG_DIR/config.json"
: > "$LEGACY_GOST_CONFIG_DIR/gost.json"
local systemctl_calls=""
systemctl() {
systemctl_calls+=$'\n'"$*"
return 1
}
cleanup_legacy_gost_installation >/dev/null
[[ -z "$systemctl_calls" ]] || fail "OpenRC cleanup should not invoke systemctl"
[[ ! -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "OpenRC cleanup should still remove the legacy service file"
)
test_install_script_accepts_proxy_url_env_without_prompt() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/install.sh"
@@ -558,7 +525,6 @@ test_update_panel_asks_for_proxy_config() (
load_script_without_main "$ROOT_DIR/panel_install.sh"
local ask_called="0"
local calls=""
ask_proxy_config() {
ask_called="1"
@@ -571,15 +537,6 @@ test_update_panel_asks_for_proxy_config() (
DOCKER_CMD="true"
}
resolve_panel_deployment() {
calls+=" resolve"
PANEL_DEPLOY_DIR=$(mktemp -d)
}
validate_panel_update_environment() {
calls+=" validate-current"
}
get_current_db_type() {
echo "sqlite"
}
@@ -588,33 +545,13 @@ test_update_panel_asks_for_proxy_config() (
echo "v-test"
}
download_panel_update_compose() {
calls+=" download"
UPDATE_COMPOSE_CANDIDATE="candidate"
}
validate_panel_update_compose() {
calls+=" validate-new"
}
create_panel_update_backup() {
calls+=" backup"
UPDATE_BACKUP_DIR="backup"
}
pull_panel_update_images() {
calls+=" pull"
}
activate_panel_update_files() {
calls+=" activate"
}
start_panel_after_update() {
calls+=" start"
}
upsert_env_var() { :; }
check_ipv6_support() { return 1; }
configure_docker_ipv6() { :; }
docker() { return 0; }
wait_for_backend_healthy() { return 0; }
sleep() { :; }
curl() { :; }
update_panel >/dev/null
@@ -623,75 +560,6 @@ test_update_panel_asks_for_proxy_config() (
"https://github.com/${REPO}/releases/download/v-test/docker-compose-v4.yml" \
"$DOCKER_COMPOSEV4_URL" \
"update_panel should honor the prompted proxy choice"
assert_equals \
" resolve validate-current download validate-new backup pull activate start" \
"$calls" \
"update_panel should validate, back up, and pull before activating the update"
)
test_resolve_panel_deployment_uses_container_labels() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
local expected_dir
expected_dir=$(mktemp -d)
expected_dir=$(cd "$expected_dir" && pwd -P)
printf 'JWT_SECRET=test\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$expected_dir/.env"
printf 'services: {}\n' > "$expected_dir/docker-compose.yml"
unset PANEL_DEPLOY_DIR COMPOSE_PROJECT_NAME
docker() {
if [[ "$1" == "inspect" && "$2" == "-f" ]]; then
case "$3" in
*working_dir*) printf '%s\n' "$expected_dir" ;;
*) printf '%s\n' "flvx-panel" ;;
esac
fi
}
resolve_panel_deployment >/dev/null
assert_equals "$expected_dir" "$PANEL_DEPLOY_DIR" "update should discover the Compose working directory from container labels"
assert_equals "flvx-panel" "$COMPOSE_PROJECT_NAME" "update should preserve the existing Compose project name"
assert_equals "$expected_dir" "$(pwd -P)" "update should run from the discovered deployment directory"
)
test_validate_panel_update_environment_rejects_empty_required_values() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
local deploy_dir rc="0"
deploy_dir=$(mktemp -d)
printf 'JWT_SECRET=\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$deploy_dir/.env"
printf 'services: {}\n' > "$deploy_dir/docker-compose.yml"
cd "$deploy_dir"
DOCKER_CMD="true"
validate_panel_update_environment >/dev/null || rc="$?"
assert_equals "1" "$rc" "update should reject an empty JWT secret before touching the running service"
)
test_backup_sqlite_for_update_pauses_copies_and_unpauses() (
set -euo pipefail
load_script_without_main "$ROOT_DIR/panel_install.sh"
local destination calls log_path
destination=$(mktemp -d)/sqlite
log_path=$(mktemp)
PANEL_BACKEND_CONTAINER="flux-panel-backend"
docker() {
if [[ "$1" == "inspect" ]]; then
printf 'true\n'
return 0
fi
printf ' %s' "$1" >> "$log_path"
}
backup_sqlite_for_update "$destination" >/dev/null
calls=$(cat "$log_path")
assert_equals " pause cp unpause" "$calls" "SQLite backup should pause writes, copy the database volume, then resume the backend"
)
test_panel_install_script_accepts_proxy_url_env_without_prompt() (
@@ -757,14 +625,10 @@ test_install_flux_agent_uses_openrc
test_remove_flux_agent_service_uses_openrc
test_cleanup_legacy_gost_installation_removes_service_and_binary
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc
test_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_can_disable_proxy
test_panel_install_script_recomputes_compose_urls_after_prompt
test_update_panel_asks_for_proxy_config
test_resolve_panel_deployment_uses_container_labels
test_validate_panel_update_environment_rejects_empty_required_values
test_backup_sqlite_for_update_pauses_copies_and_unpauses
test_panel_install_script_uses_default_proxy
test_panel_install_script_accepts_proxy_url_env_without_prompt
test_panel_install_script_defaults_proxy_on_eof
+4 -4
View File
@@ -1,10 +1,10 @@
# 多阶段构建 - 构建阶段
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY package.json pnpm-lock.yaml* ./
RUN corepack prepare pnpm@10 --activate && corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
@@ -18,4 +18,4 @@ COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
+7 -1
View File
@@ -2,7 +2,6 @@
"name": "flvx",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.28.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -82,5 +81,12 @@
"vite": "npm:rolldown-vite@^7.3.1",
"vite-plugin-pwa": "^1.1.0",
"vite-tsconfig-paths": "^6.0.5"
},
"pnpm": {
"overrides": {
"@babel/plugin-transform-modules-systemjs": "7.29.4",
"fast-uri": "3.1.2",
"serialize-javascript": "7.0.5"
}
}
}
-8
View File
@@ -1,10 +1,2 @@
packages:
- '.'
overrides:
'@babel/plugin-transform-modules-systemjs': 7.29.4
fast-uri: 3.1.2
serialize-javascript: 7.0.5
allowBuilds:
'@tailwindcss/oxide': true
+2 -7
View File
@@ -28,7 +28,6 @@ import { isLoggedIn } from "@/utils/auth";
import { siteConfig, updateSiteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode";
import { SESSION_UPDATED_EVENT } from "@/utils/session";
import { useThemeContext } from "@/themes/context";
const ProtectedRoute = ({
children,
@@ -80,7 +79,6 @@ const LoginRoute = () => {
function App() {
const location = useLocation();
const navigate = useNavigate();
const { effectiveMode } = useThemeContext();
// 全局登录状态监听,当检测到未登录且不在首页时,跳转到首页
useEffect(() => {
@@ -100,10 +98,7 @@ function App() {
// 处理自定义背景图片
useEffect(() => {
const updateBg = () => {
const customBg =
(effectiveMode === "dark"
? siteConfig.app_bg_image_dark
: siteConfig.app_bg_image_light) || siteConfig.app_bg_image;
const customBg = siteConfig.app_bg_image;
if (customBg) {
if (customBg === "theme") {
@@ -154,7 +149,7 @@ function App() {
return () => {
window.removeEventListener("site-config-updated", updateBg);
};
}, [effectiveMode]);
}, []);
// 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => {
-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);
-3
View File
@@ -19,7 +19,6 @@ export interface UserApiItem {
name?: string;
status: number;
flow: number;
flowMiB?: number;
num: number;
expTime?: number;
flowResetTime?: number;
@@ -107,7 +106,6 @@ export interface UserTunnelPermissionApiItem {
tunnelName: string;
status: number;
flow: number;
flowMiB?: number;
num: number;
expTime: number;
flowResetTime: number;
@@ -389,7 +387,6 @@ export interface UserTunnelAssignPayload {
id?: number;
tunnelId?: number;
flow?: number;
flowMiB?: number;
num?: number;
expTime?: number;
flowResetTime?: number;
@@ -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>
);
}
@@ -1,62 +0,0 @@
import { Input } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
parseTrafficInput,
TRAFFIC_UNIT_MIB,
type TrafficUnit,
} from "@/utils/traffic";
const UNITS: TrafficUnit[] = ["MB", "GB", "TB", "PB"];
interface TrafficLimitFieldProps {
label: string;
value: string;
unit: TrafficUnit;
onChange: (value: string, unit: TrafficUnit) => void;
description?: string;
isRequired?: boolean;
}
export function TrafficLimitField({
label,
value,
unit,
onChange,
description,
isRequired,
}: TrafficLimitFieldProps) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_7rem] gap-2">
<Input
description={description}
isRequired={isRequired}
label={label}
min="0"
step="any"
type="number"
value={value}
onChange={(event) => onChange(event.target.value, unit)}
/>
<Select
aria-label={`${label}单位`}
label="单位"
selectedKeys={[unit]}
onSelectionChange={(keys) => {
const nextUnit = Array.from(keys)[0] as TrafficUnit | undefined;
if (!nextUnit) return;
const mib = parseTrafficInput(value, unit);
onChange(
mib === null ? value : String(mib / TRAFFIC_UNIT_MIB[nextUnit]),
nextUnit,
);
}}
>
{UNITS.map((option) => (
<SelectItem key={option}>{option}</SelectItem>
))}
</Select>
</div>
);
}
+22 -102
View File
@@ -14,16 +14,10 @@ const PUBLIC_BRAND_CONFIG_KEYS = [
"app_logo",
"app_favicon",
"app_bg_image",
"app_bg_image_light",
"app_bg_image_dark",
"is_commercial",
"hide_footer_brand",
] as const;
const SENSITIVE_CONFIG_KEYS = new Set([
"jwt_secret",
"license_key",
"license_machine_id",
"machine_fingerprint",
"cloudflare_secret_key",
]);
const GITHUB_REPO =
@@ -55,30 +49,6 @@ const readCachedConfigs = (keys: readonly string[]) => {
return { cachedConfigs, hasCachedData };
};
const readAllCachedSafeConfigs = () => {
const cachedConfigs: Record<string, string> = {};
Object.keys(localStorage).forEach((storageKey) => {
if (!storageKey.startsWith(CACHE_PREFIX)) {
return;
}
const key = storageKey.slice(CACHE_PREFIX.length).trim().toLowerCase();
if (!key || SENSITIVE_CONFIG_KEYS.has(key)) {
return;
}
const value = localStorage.getItem(storageKey);
if (value !== null) {
cachedConfigs[key] = value;
}
});
return cachedConfigs;
};
const fetchPublicBrandConfigs = async (): Promise<Record<string, string>> => {
const publicConfigMap: Record<string, string> = {};
@@ -116,8 +86,6 @@ const getInitialConfig = () => {
app_logo: "",
app_favicon: "",
app_bg_image: "",
app_bg_image_light: "",
app_bg_image_dark: "",
is_commercial: false,
hide_footer_brand: false,
};
@@ -131,10 +99,6 @@ const getInitialConfig = () => {
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const cachedAppBgImage =
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
const cachedAppBgImageLight =
localStorage.getItem(CACHE_PREFIX + "app_bg_image_light") || "";
const cachedAppBgImageDark =
localStorage.getItem(CACHE_PREFIX + "app_bg_image_dark") || "";
const isCommercial =
localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand =
@@ -142,17 +106,15 @@ const getInitialConfig = () => {
if (cachedAppName) {
return {
name: isCommercial ? cachedAppName : "FLVX",
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
app_logo: isCommercial ? cachedAppLogo : "",
app_favicon: isCommercial ? cachedAppFavicon : "",
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
app_bg_image_light: cachedAppBgImageLight,
app_bg_image_dark: cachedAppBgImageDark,
is_commercial: isCommercial,
hide_footer_brand: isCommercial && hideFooterBrand,
hide_footer_brand: hideFooterBrand,
};
}
@@ -161,13 +123,11 @@ const getInitialConfig = () => {
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
app_logo: isCommercial ? cachedAppLogo : "",
app_favicon: isCommercial ? cachedAppFavicon : "",
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
app_bg_image: cachedAppBgImage,
app_bg_image_light: cachedAppBgImageLight,
app_bg_image_dark: cachedAppBgImageDark,
is_commercial: isCommercial,
hide_footer_brand: isCommercial && hideFooterBrand,
hide_footer_brand: hideFooterBrand,
};
};
@@ -246,24 +206,20 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
const {
cachedConfigs: publicCachedConfigs,
hasCachedData: hasPublicCachedData,
} = readCachedConfigs(PUBLIC_BRAND_CONFIG_KEYS);
const { cachedConfigs, hasCachedData } = readCachedConfigs(
PUBLIC_BRAND_CONFIG_KEYS,
);
if (!isLoggedIn()) {
const publicConfigs = await fetchPublicBrandConfigs();
if (Object.keys(publicConfigs).length > 0) {
return { ...publicCachedConfigs, ...publicConfigs };
return { ...cachedConfigs, ...publicConfigs };
}
return publicCachedConfigs;
return cachedConfigs;
}
const cachedConfigs = readAllCachedSafeConfigs();
const hasCachedData = Object.keys(cachedConfigs).length > 0;
// 从API获取最新配置
try {
const response = await getConfigs();
@@ -293,20 +249,14 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
return cachedConfigs;
}
const publicConfigs = await fetchPublicBrandConfigs();
return { ...publicCachedConfigs, ...publicConfigs };
return await fetchPublicBrandConfigs();
} catch {
// API失败时返回缓存的数据
if (hasCachedData) {
return cachedConfigs;
}
const publicConfigs = await fetchPublicBrandConfigs();
return hasPublicCachedData
? { ...publicCachedConfigs, ...publicConfigs }
: publicConfigs;
return await fetchPublicBrandConfigs();
}
};
@@ -394,21 +344,7 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
resolvedConfigMap,
"app_bg_image",
);
const hasAppBgImageLight = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_bg_image_light",
);
const hasAppBgImageDark = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_bg_image_dark",
);
const resolvedCommercial = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"is_commercial",
)
? resolvedConfigMap.is_commercial === "true"
: siteConfig.is_commercial;
const appName = hasAppName
? String(resolvedConfigMap.app_name || "").trim()
: siteConfig.name;
@@ -421,32 +357,16 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
const appBgImage = hasAppBgImage
? String(resolvedConfigMap.app_bg_image || "").trim()
: (siteConfig.app_bg_image || "").trim();
const appBgImageLight = hasAppBgImageLight
? String(resolvedConfigMap.app_bg_image_light || "").trim()
: (siteConfig.app_bg_image_light || "").trim();
const appBgImageDark = hasAppBgImageDark
? String(resolvedConfigMap.app_bg_image_dark || "").trim()
: (siteConfig.app_bg_image_dark || "").trim();
siteConfig.name = resolvedCommercial && appName ? appName : "FLVX";
siteConfig.app_logo = resolvedCommercial ? appLogo : "";
siteConfig.app_favicon = resolvedCommercial ? appFavicon : "";
if (appName && appName !== siteConfig.name) {
siteConfig.name = appName;
}
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
siteConfig.app_bg_image = appBgImage;
siteConfig.app_bg_image_light = appBgImageLight;
siteConfig.app_bg_image_dark = appBgImageDark;
if (
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial")
) {
siteConfig.is_commercial = resolvedCommercial;
}
if (
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "hide_footer_brand")
) {
siteConfig.hide_footer_brand =
resolvedCommercial && resolvedConfigMap.hide_footer_brand === "true";
} else if (!resolvedCommercial) {
siteConfig.hide_footer_brand = false;
}
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
if (typeof document !== "undefined") {
document.title = siteConfig.name;

Some files were not shown because too many files have changed in this diff Show More