16 Commits
v0.1.0 ... main

Author SHA1 Message Date
918ca5d5ba fix(cluster): 停用状态以「环」为权威,消除长期离线导致的永久分歧
承接用户指出的遗留:links 表是每节点本地副本,靠「store→环上行 + 环→store
下行」双向 sync 收敛,但两边都可能被覆盖。其中「本地 store 权威」这条规则有
硬伤,本次改掉。

## 先复现,再动手

写探针验证「入环 token 会不会冲掉本地未传播的决定」,结果比预期严重:

    after adopting a stale token: known=true disabled=false
    LOCAL DISABLE WAS WIPED by an incoming token

OnToken/AdoptState 是 `e.state = tk.State` **整体替换**。两次 token 之间做出的
停用决定,只要下一轮到达的 token 是「决定之前」捕获的,就会被整个洗掉 ——
决定永远传不出去,转发照旧运行。Group 之所以看起来没这个问题,是因为它每次
adoption 都被 `SetTopologySync` 从 store 重新推上去,而 disabled 没有对应的
「尚未传播」保护。

## 改法:环权威 + 本地决定带「未确认」标记

1. **环权威**:adoption 时把环上的 disabled 写穿本地 store(write-through,
   不是监听器),任何 peer 的决定都在一个 token 周期内落地。这终结了旧规则
   「各人信自己那份」造成的永久分歧。顺序上只有单向要求:引擎先重新断言本地
   未确认决定,再跑 host sync,所以读环绝不会覆盖用户刚做的操作。

2. **未确认决定受保护**(RingEngine.localDisabled):`UpdateTopologyDisabled`
   记下决定,adoption 后由 `reconcileLocalDisabled` 重新断言到刚采纳的 state 上,
   于是它会随下一轮 token 传出去。环报回同值时删除该键(全cluster已一致);
   转发从 topology 消失时一并清扫,map 不会无限增长。**false 同样受保护** ——
   重新启用也需要传播,丢掉它会把转发永久留在停用态。

3. `TopologyDisabled()` 对未确认决定短路返回本地值,避免状态页与用户刚做的
   操作相反。

## 测试(又抓到一个假绿)

新增 4 条:停用/启用跨 adoption 存活、确认后停止断言(让位给 peer 的后续决定)、
追踪表不累积。

★ `TestLocalDisableSurvivesAdoption` **第一版是假绿**:它断言
`TopologyDisabled()`,而该访问器会短路到本地决定,于是「即便即将转发的 state
仍是 enabled」它也报 true。改成断言**下游节点会看到什么**(用一个 peer 引擎
AdoptState 本引擎的 state)后,去掉重新断言如期变红:

    the forwarded token carries disabled=false, want true

双向验证通过,这个教训要记:测「声明」而不是测「实际传播的状态」,等于没测。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 11:44:28 +08:00
c8ab2584a8 chore(cluster): 去掉 restart 的重复日志行
实测 .30 在处理一个 restart 任务时打出两条完全相同的
`restarted t4: portal→aliyun-frps:4434`(同毫秒、同内容),一度像是执行了两次。
核实 worker 进程只有一个(restart 对已运行进程是幂等的),所以不是重复执行 ——
是引擎层(ring_engine.go)与应用层(RestartFn)各打了一条同样的日志。

保留应用层那条:它紧跟实际 worker 操作(谁真正把进程拉起来的),信息位置更准;
引擎层只保留 discard/no-owner 这类异常路径的日志,一次重启对应一行。

go build / go vet / go test ./... 全绿。
2026-09-26 11:06:14 +08:00
8ab52b238a fix(cluster): restart 任务必须定向投递给 owner,不能被非 owner 吞掉
上一提交(5cdc052)只加了执行侧 owner 判定,实测仍然失败:从 .60(非 owner)
启动 owner 在 .30 的 portal,.30 的 worker 一直没起来,且三台日志里既没有
`restarted` 也没有任何错误。

## 真因:任务被非 owner「消费」掉了

pending 命令由 runCommands 的 `for {}` 循环每轮重取 PendingList(),取出后
ClaimPending 即从 map 移除。我当时在「owner != e.ID」时把任务塞回
PendingTasks —— 于是它**立刻又变回待处理**,下一轮循环再次取到,无限
`defer restart`(单测直接跑成死循环,300s 超时)。

而上一版的 `continue` 同样是错的:ClaimPending 已经把任务移除,continue
等于消费,owner 永远收不到。

## 修法:放进「选择」循环,和 Revoke 完全同构

runCommands 里 Revoke 早就有正确的定向投递范式:
    owner == e.ID        → 我持有,执行
    owner == "" 且最低负载 → 转发已消失,兜底消费
    否则                  → continue(任务**留在 token 里**随环前进)
restart 照抄这套。非 owner 只是不选中它,任务随 token 传给下一个节点,直到
owner 那一跳被取走。owner 已消失也不会永远飘着(`owner == ""` 由最低负载
节点兜底消费),与 Revoke 的处理一致。

执行侧的 owner 判定保留为第二道防线(双保险,两层各有测试覆盖)。

## 测试(又抓到一次假绿 + 一次死循环)

- TestRestartTaskReachesNonLocalOwner:非 owner 处理一 token 后任务必须仍在
  token 里,随后 owner 处理时恰好应用 1 次。
  ★ 第一次写它时反复把**同一个 State 值**喂回 OnToken,导致死循环;改成按
  真实环的走法(每跳喂一个新 token)后正常。
- TestRestartOnlyAppliedByOwner:把 peer 设成**最低负载节点**(否则泛用认领
  分支根本不会触发,测了等于没测),断言它也不得应用。
  ★ 第一版 peer 不是最低负载节点 ⇒ 删掉 selection 分支后测试仍然绿,是假绿;
  改为最低负载后,删分支 → TestRestartTaskReachesNonLocalOwner 变红。
- 其余:TestRestartTaskBypassesDuplicateClaimGuard、TestRestartFlagSurvives
  TokenSerialization 保持绿。

★ 第三次「双向验证」的价值:一个测试抓不到,**另一个**抓到了。单靠一个测试
的绿就下结论是不安全的。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 11:03:00 +08:00
5cdc052d01 fix(cluster): restart 任务必须只由 owner 执行
上线实测抓到的:从 .60(非 owner)启动 owner 在 .30 的 portal,日志出现
**两条** `.60 restarted t4` —— 关键帧是 `[192.168.2.60:7500] restarted t4`。
pending 任务对所有成员可见,而 restart 分支没有 owner 判定,于是谁先轮到
谁就执行:非 owner 给自己的机器起了一个属于别人的转发 worker,而真正的
owner(.30)什么也没做,worker 一直没起来。

这正是上面 duplicate-claim 防御本来要防的「孤儿 worker + 重复认领」,只是
restart 分支为了绕开那层防御,把 owner 校验也一并跳过了 —— 绕开的是
「重复建条目」的必要性,不是「只有 owner 能动手」的必要性。

修法与 RemoveNode 分支一致:`owner != e.ID` 时只清标志、不执行 handler。
owner 已消失的情况不是错误:条目已被重新标为启用,OfflineReassign() 会在
下一次离线清理时把它转入 pending,再由正常认领流程重新安置。

测试 TestRestartOnlyAppliedByOwner:同一份 restart 任务分别交给 owner 与非
owner,断言非 owner 调用 0 次、owner 恰好 1 次。双向验证 —— 去掉守卫后
如期变红("a NON-owner applied the restart 1 time(s)"),还原后变绿。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:48:28 +08:00
1c835425de feat(cluster): 停用改为「标记」语义,让 disabled 真正随令牌环跨节点传播
承接用户提问「设计上停用不是本来就会跨节点传输吗」——核实结论:结构上确实
如此(TopoEntry.Link 是完整 store.Link,整个 State 随 token 每轮广播),但
实际路径断了。断点正是「撤销会删掉 topology 条目」:条目是 flag 的载体,
删了就无处传播,于是停用只能靠一次性 revoke 任务投递给 owner,**owner 当时
不在线就收不到**(实测 .60 记 disabled=1 / .106 记 0,就是这么来的)。

## 改为标记而非移除

撤销不再 RemoveTopology,而是 UpdateTopologyDisabled(true),条目保留、
Link.Disabled=true、Active=false。Active 正是为此存在:OfflineReassign()
只处理 Active 条目,所以停用的转发在 owner 掉线时不会被重新排队。

- 新增 UpdateTopologyDisabled / TopologyDisabled(照 UpdateTopologyGroup 的桥)
- 新增 store.ReconcileLinkDisabled 作接收端:adoption 时把环上的 flag 落进
  本地 store;本节点没有该转发时补一条 disabled 占位行(否则日后在本节点被
  claim 会复活),enable 则不建行
- SetTopologySync 由单向(store→环)扩为双向:群组仍上行,disabled 下行
- AddTopology 的 Active 跟随 Link.Disabled(原本硬编码 true,认领一个停用
  转发就会复活它)
- 审计日志细分 forward.stop / forward.start,与 forward.remove 区分

## 语义变更带出的两个新问题(都已修)

1. **「启动」这条路断了**。条目保留 ⇒ SubmitTask 被去重挡下,而认领路径的
   duplicate-claim 防御又会丢弃「已有 owner」的任务 ⇒ 重启任务发不出去,owner
   永远收不到,转发**能停不能起**。
   修:新增 Task.Restart 这一独立任务类型 + SubmitRestart + Handler.RestartFn,
   显式绕过 duplicate-claim 防御并原地复活(不重复建条目、不重跑 claim 簿记)。
   SubmitTask 的守卫同时从 HasTask 收窄为新的 HasActiveTask(跳过 disabled 条目
   与撤销任务);saveCanvas 的判断相应改用 HasActiveTask,避免每次保存都对
   已标记的转发重复发撤销。

2. 原本两处 RemoveTopologyEntry 调用(ClaimFn/RevokeFn 的 disabled 分支)在
   新语义下会把本该保留的条目删掉,改为 UpdateTopologyDisabled。

## 测试(每个都做了「回退修复行→必须变红→还原变绿」双向验证)

- TestStoppedTopologyEntrySurvivesAdoption —— 离线成员也能学到停用,
  一次性 revoke 任务永远做不到这一点
- TestStoppedForwardNotRequeuedOnNodeDeparture / TestAddTopologyRespectsDisabledFlag
  —— 标记而非删除为何安全
- TestSubmitTaskNotBlockedByStoppedEntry / TestSubmitTaskStillDedupesActiveForward
- TestRestartTaskBypassesDuplicateClaimGuard / TestRestartFlagSurvivesTokenSerialization
- TestStopThenStartPublishesRestartTask(HTTP 端到端,断言**任务真的发出**)
- TestReconcileLinkDisabled*(store 侧三条)

★ 两次踩到**假绿**:第一版只断言 store 层(newTestHandler 的 Ring 为 nil,
坏掉的路根本没执行);第二版在 re-enable **之后**才调 SubmitTask,此时新旧
谓词结果相同,测不出差异。都是靠「回退修复行看是否变红」抓出来的 —— 这个
双向验证已经是本项目的固定动作。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:44:04 +08:00
041cc04dd6 fix(cluster): revoke 在无 topology 条目时静默失效 + 停用状态跨节点不同步
上一提交(46e8bc3)上线后实测:`.106` 重启后,被用户停用的
`minecraft` worker **仍然被拉起**。继续挖出两处,均为静默失效型。

## 1. Handler.Revoke 被 RemoveTopology 的返回值挡住了

    if claimed.Revoke {
        if e.state.RemoveTopology(...) {      // ← false 时整个块跳过
            if e.Handler != nil { e.Handler.Revoke(...) }
        }
    }

停 worker 的唯一动作被关在「topology 里确实有条目」的条件里。而
RemoveTopology 对**不在 topology 的转发返回 false** —— 也就是说,越是
需要清理的僵尸转发(条目已丢、worker 还在),revoke 越是完全不做。

这正是 minecraft 的处境:停用请求到达时它已不在 topology ⇒ 返回 false
⇒ Revoke 不执行 ⇒ worker 永远不停 ⇒ 每 33 秒刷一次 connection refused。

修法:停 worker 与摘条目是**两件独立的事**,条目不在也照样停。
(审计日志 forward.remove 仍只在真的摘掉条目时写,这是对的。)

## 2. 停用状态是节点本地的,从不跨节点同步

`links` 表每节点各一份。实测同一时刻 `.60` 记 disabled=1、`.106` 记
disabled=0 —— 因为只有收到停用请求的那个节点写了 store,而**持有 worker
的 owner 往往是另一台机器**,它的副本仍是"启用"。Claim 只读本地 store
⇒ owner 认为该转发是启用的 ⇒ 又被拉起。

修法:task 已携带 Disabled,以它为准,RevokeFn 在本节点把它落库
(已有行改写;没有行则补一条 disabled 记录,防止日后在本节点被 claim
时复活)。

## 测试

- TestRevokeIdempotent 原先只断言"不 panic",正好漏掉这个 bug —— 它
  允许「Handler.Revoke 从不调用」通过。现补上断言:拓扑里没有条目时
  **仍必须调用** Handler.Revoke。
- 验证该测试有效:把 `&& removed` gate 加回去 → 如期变红;还原后变绿。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:05:55 +08:00
46e8bc3703 fix(cluster): 停用的转发会在重启后自己复活 + 令牌轮转日志刷屏
从线上三节点(192.168.2.{30,106,60})的日志里挖出四个问题,本轮修三个。

## 1. 停用的转发会复活(功能性缺陷,实测仍在发生)

线上现象:`minecraft` 在 store 里 disabled=1,worker 却仍在跑,今天
09:46 还在刷 `connect to local service [192.168.2.60:25565]: connection
refused` —— 对着一个用户刻意没启的本地服务死刷。同时三台 logs 里躺着
13956 / 3769 条 `proxy [x] already exists`,每 33 秒一轮。

四处叠加导致:
- stopForward 先读 link,再 SetLinkDisabled(true),然后把**改之前**的
  副本交给 RevokeTask ⇒ published task 带 disabled=false(实测 id/flag
  都对不上:topology 里 link.id=223,store 里同一行是 199)
- ClaimFn **无条件** SetLinkDisabled(...,false)。原意是"重新认领时清掉
  停用标记",但启动 reconcile 只要 worker 不在就重新 Claim ⇒ 每次重启
  都是"先清标记再起 worker"
- RevokeFn 停了 worker 却**没删 topology 条目**,条目活过 worker
- 于是下次重启 reconcile 看到"owned 但 worker 不在"→ 再次 Claim → 死循环

修法(把 disabled 的所有权交回两个用户动作):
- Claim **只读** disabled 决定要不要起 worker;为 true 时连 topology
  条目一起摘掉,绝不复活
- 启动 reconcile 先按 store 跳过 disabled 的条目(省掉无谓的
  claim→skip 往返)
- RevokeFn 除停 worker 外,同时 RemoveTopologyEntry —— 撤销必须是
  完整退役,不能只是"停一下"
- stopForward 把 Disabled=true 随 task 发布出去,让持有该转发的节点
  即使本地 store 行陈旧也能判断这次停用是用户主动的

## 2. 令牌轮转日志零信息量却占满磁盘

每轮固定 3 行(OnToken cycle=N / forward cycle=N / token-send -> 200),
2 轮/秒,实测本机 **355 行/分钟、7 天 357 万行**,把真事件全淹了。
同一份信息(cycle / lastSync / roundDelayMs / 成员存活)本来就能从
GET /api/manager/cluster/ring 结构化拿到。

加 W4F_DEBUG 开关(沿用项目既有 W4F_ 前缀约定):稳态三行降级为 debug、
默认关闭;**失败路径一律保留** —— 发送失败、陈旧令牌、非 2xx 正是别人
grep 的对象,静音它们是坏交易。实测同样 12 秒:36 行 → 4 行。

## 3. Link.ID 在 ReplaceLinks 之后必然失效

ReplaceLinks 是 DELETE + 重新 INSERT,sqlite 给每行**新的自增 id**。
任何在改写前捕获的 Link(典型:随 token 环跑的 Link)手里的 id 要么查无
此行,要么命中另一条转发 —— 实测捕获 alpha id=1,改写后新表是 3/4/5,
GetLink(1) 直接落空。

新增 LinkByTriple(local, remote, port) 按自然键查(业务代码本来就一律用
这个三元组标识转发),并把 claim/reconcile 切过去。查无行返回
(Link{}, false, nil) 而非 error:新建的转发没有行,应当照常启动。

## 4. homeagent_device 孤儿(已澄清,非独立缺陷)

它 disabled=1 且从不在 topology 里,是缺陷 1 的另一面(停用标记没进
token),随本次修复覆盖,无需单独处理。

## 测试

新增 4 个测试文件,重点是**验证测试本身抓得住 bug**:
- 临时回退 `ln.Disabled = true` 这行 → TestStopForwardPublishesDisabled-
  FlagInRevokeTask 如期变红,还原后变绿
- ⚠️ 第一版回归测试只断言 store 层,是**假绿**:newTestHandler 的 Ring
  为 nil,RevokeTask 那条(真正坏掉的)路根本没执行。补了带 ring 的
  newRingTestHandler,直接断言**发布出去的 task 上的 flag**
- LinkByTriple 在 ReplaceLinks 前后保持稳定;GetLink(id) 的失效被固化成
  一个可见的说明性测试
- 停用/start 往返、per-forward 停用不误伤兄弟转发
- 错误路径不静音、W4F_DEBUG 各种取值

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:02:55 +08:00
f4fb964734 fix(web): 窄屏布局三处根因 + 页面滚动塌陷
在 375/320 屏上实机取证(三节点集群 192.168.2.{30,106,60}),修掉四个
独立缺陷。前三个是布局根因,第四个是被前三个掩盖的真 bug。

1. 全站没有 box-sizing 重置
   content-box 下 `.sidebar { width:100%; padding:10px 12px }` 在 375 视口
   实算 399px(320 屏 344px)。顶栏主题色块右缘 332 > 320,被
   `.sidebar{overflow-x:hidden}` 静默裁掉 ⇒ 主题切换在手机上点不到。
   桌面侧栏同样是 274px 而非声明的 246px。
   注:已有两个「窄屏顶栏右侧溢出」commit(64d3359 用 flex min-width/vw
   限宽、0559cc5 改 backdrop-filter)都在治症状,真因是盒模型。

2. fit-view-on-init 把画布缩到不可读
   画布用绝对图形坐标(x=60/760,两列随转发数向下增长),bbox 远大于
   手机视口。实测合成缩放 375 屏 0.3135 ⇒ 248px 的卡片渲成 85px、
   有效字号 4.39px;**1280 桌面也只有 0.6011 / 8.42px**(5 条转发即
   触发),所以这不只是窄屏问题。
   改为删掉 fit-view-on-init 自己接管:MIN_ZOOM=0.65 下限 +
   frameCanvas() 把最左上节点重贴到内边距边缘(fitView 是居中的,窄屏
   下会把两列接缝摆在视口正中,两边各看一半)。duration:0 保证量 rect
   时动画已停;初始 frame 只认一次,避免后续 add 节点把正在平移的用户
   拽走。挂 @init + @nodes-initialized(store 与节点分先后到达,任一
   单独触发都会 fit 空画布)。

3. el-dialog 固定宽度超出手机视口
   Element Plus 把 width 写成内联 --el-dialog-width。375 屏实测
   left=0 right=420,右上关闭按钮在屏外,只能横向滚 overlay 才够得到。
   窄屏下 clamp 到 calc(100vw - 24px)。

4. 【滚动塌陷】状态页在手机上完全无法滑动
   `.status-page` 是 height:100% + overflow:hidden 的 flex 列,内层
   `.status-body` 靠 flex:1 + overflow-y:auto 自己滚。375 屏下可用高度
   651px,而两个不可压缩子元素 topbar(122) + kpis(565) 已占 687px ⇒
   `.status-body` 被分到 **0px**,其中却有 1682px 内容。同时外层
   `.content` scrollHeight==clientHeight(无可滚内容)⇒ 整页没有任何
   可滚动元素,手势完全无响应。
   修法:让 `.content`(App.vue 已声明 flex:1 + overflow-y:auto)做唯一
   滚动容器,页面自身不再 height:100%/overflow。ClusterView /
   SettingsView 同类嵌套一并去掉,从根上消灭「按剩余空间算高度」这类
   算术错误。另把 ≤380px 的 KPI 单列改回两列(单列四张全宽卡自身就
   565px,超过整个视口,是塌陷的起因)。

验证(构建产物挂反代打真后端 + 共享 Chromium CDP):
- 缩放 0.31→0.65、卡片 85→177px、有效字号 4.39→9.1px(320/375/414/1280 一致)
- bodyScrollW 344→320(320 屏),横向溢出清零;主题色块全部落回屏内
- 对话框 left 12 / right 363(完全在屏内,关闭键可达)
- 9 种视口 × 5 个页面 = 45 格全部「末块可达 + 无横向滚动」,console 零报错
  修复前:状态页 375/320/414 全部无滚动;1280 桌面状态/设置同样中招
- 三节点滚动重启部署,集群收敛 pending=0 / topo=4,owner 与实际
  worker 进程一一对应,journal 无 panic/fatal,reclaimed=2 各节点自愈
2026-09-18 11:00:35 +08:00
64d3359981 fix(web): 窄屏顶栏右侧溢出 — flex 自动最小尺寸 + vw 限宽两处误算
两个原因叠加导致顶栏横向溢出:

1. "webui4frpc" 是不可断行单词,.sb-brand-text 没给 min-width:0 +
   overflow:hidden,其 flex 自动最小尺寸就等于 min-content(≈85px),
   顶栏因此拿不出空间给右侧身份区,超出部分溢出。现给品牌文字整条链路
   加可收缩约束,标题用 ellipsis 截断。

2. .sb-identity 用 max-width:42vw / .id-name 用 14vw 限宽:vw 含垂直
   滚动条宽度,比实际可用内容宽度大;且 max-width 只封顶盒子,内部
   flex:0 0 auto 的角色徽标与退出按钮仍会把它撑开。改为全链路
   min-width:0 + overflow:hidden,由 flex 自行分配(.sb-foot 也从
   flex:0 0 auto 改 0 1 auto,允许被压缩)。

另加 .sidebar { overflow-x: hidden } 作兜底,任何子项算错都不再产生
横向滚动条;≤380px 隐藏角色徽标(它不可收缩,"超级管理员"≈70px)。

验证:tsc --noEmit 通过、npm run build 通过、产物 CSS 已确认规则生效;
三节点部署 0.1.2 后环正常(cycle 推进、pending=0、topo=5)。
2026-09-03 08:50:23 +08:00
0559cc55bd fix(web): 窄屏底部导航栏错位到顶部 — 顶栏 backdrop-filter 创建了包含块
.sb-nav 用 position:fixed;bottom:0 想贴视口底部,但父元素 .sidebar 带
backdrop-filter。backdrop-filter 与 transform/filter 同理,会为后代的
position:fixed 创建【包含块】—— 于是 bottom:0 变成相对顶栏下沿定位而不是
视口,tab bar 贴在顶栏正下方,看起来就是固定在屏幕顶部。

窄屏下关掉顶栏的 backdrop-filter,改用近实色底
color-mix(var(--w4f-card-solid) 92%, transparent);玻璃模糊保留在
.sb-nav 自身(元素自己的 backdrop-filter 不影响自己的定位)。

顺带 bump 到 0.1.2(version 包 / release.sh / build_installers.sh /
rpm spec + changelog / README 安装示例文件名)。

验证:tsc --noEmit 通过、npm run build 通过;产物 CSS 中确认 720px 段的
.sidebar 已含 backdrop-filter:none;三节点部署后 /status 报告 0.1.2,
环正常(cycle 推进、pending=0、5 条转发 frpc 进程与 topology 归属一致)。
2026-09-03 08:45:22 +08:00
609f0df517 chore: upload_assets.py 默认包含原生安装包
默认文件筛选原来只匹配 tar.gz/zip/SHA256SUMS,deb/rpm/pkg/setup.exe
必须逐个显式传参才会上传(v0.1.0 与 v0.1.1 都是分两趟传的)。
改为按发布产物后缀白名单筛选;用 -setup.exe 而非裸 .exe,避免误收
bin/ 下的裸二进制。
2026-09-03 07:30:53 +08:00
f9f18ae13c chore: v0.1.1 — 版本号收拢进 internal/version + 发布脚本校验和覆盖安装包
- 新增 internal/version 包作为版本号唯一来源,release.sh 通过
  -ldflags -X 注入真实 tag。此前 /api/manager/status 与集群 JoinInfo
  里各自硬编码 "0.1.0",产物报告的版本与 tag 无关。
- release.sh:SHA256SUMS 移到原生安装包构建之后统一重算,覆盖
  deb/rpm/setup.exe/pkg。此前只算 tar.gz/zip,用户校验安装包会得到
  "no file was verified"(v0.1.0 发布时实际踩到)。
- rpm spec / build_installers.sh 默认版本与 changelog、README 安装
  示例文件名同步到 0.1.1。

验证:go test ./internal/cluster/... ./internal/httpapi/... 通过;
6 平台产物架构逐个 file 校验正确;linux-amd64 实跑 /status 报告
version=0.1.1(确认 ldflags 注入生效)。
2026-09-03 07:28:13 +08:00
f935cad33f feat(web): 窄屏适配 — 侧栏转底部 tab bar + 各页响应式重排
此前只有一个 max-width:900px 断点(仅缩窄侧栏),手机上 210px 侧栏吃掉
半屏、卡片网格横向溢出、按钮被挤成细条。新增 720px / 380px 两档断点:

App.vue(关键)
- ≤720px:shell 转竖向,导航从侧栏抽出固定到屏幕底部(图标上/文字下),
  品牌+身份+主题折进顶部窄条;tab 最小 44px 触控高度
- 内容区预留 env(safe-area-inset-bottom),避开 iOS 手势条
- ≤380px 再压一档:隐藏权限徽标、缩小字号

StatusView
- card-grid 的 minmax(320px) 在 375px 屏溢出 → 单列
- KPI 由 auto-fit 改固定两列(auto-fit 窄屏退化单列使四卡拉长),≤380px 单列
- 转发卡 fwd-head 原一行塞 7~8 元素 → 路由占整行、按钮另起一行平分
- 分组标题按钮不再被 margin-left:auto 压扁

ClusterView
- 环拓扑改竖向,节点卡占满宽,箭头旋转 90° 指下
- nodeKey(32 位 hex)允许 break-all,不再撑宽容器
- 日志 lg-detail 由 max-width:60%+nowrap 改整行完整显示

SettingsView / UsersView / CanvasView
- binary-row、setting-row 竖排;表格卡标题行竖排、操作按钮平分
- 画布 toolbar 按钮换行平铺;vue-flow handle 12px→16px(触控可达)

index.html
- viewport 加 viewport-fit=cover,这是 env(safe-area-inset-*) 生效前提

验证:tsc --noEmit 通过、npm run build 通过、dist 已重新 embed,
三节点部署后环正常(pending=0,5 条转发 frpc 进程与 topology 归属一致)。
2026-09-03 07:20:32 +08:00
f9c2e9476a fix: 心跳复活节点、inflight 超时重发
1. handleClusterToken:心跳到达时把发送方(前驱)标记为 alive。
   之前 forwardToNext 超时把节点 MarkOffline 后就再无途径恢复,
   即使下一个心跳证明它活着。心跳是 leader 侧纠正误判的唯一入口。

2. forwardToNext:遍历完所有后继才返回,不要提前 inflight.clear()。
   保留 inflight 状态,让 WatchTokenLoss 超时后 StartRing 重发,
   重发时 AliveSuccessor 会重新计算,心跳刚复活的节点就能被选中。

这两处补丁一起提交,形成完整的存活复活闭环。
2026-08-28 16:32:51 +08:00
15b1dcce8f feat: 原生安装包 — deb/rpm/systemd + NSIS setup.exe + macOS pkg
此前交付的 tar.gz/zip 仅是解压直用包,补齐真正的平台安装包:
- Linux: .deb (dpkg-deb) 与 .rpm (rpmbuild),装 systemd 单元 + 专用
  系统账号 + /etc/webui4frpc 配置,非 root 运行 + 加固
- Windows: NSIS setup.exe,向导收集监听地址/凭据,注册卸载器 +
  防火墙放行(amd64/arm64)
- macOS: .pkg(在 Linux 上手工 xar+mkbom 组装 flat package),launchd
  开机自启 + env 配置文件(amd64/arm64)
- scripts/release.sh 纳入 build_installers.sh 阶段;README 增「安装」
  章节逐平台说明

已知限制:rpmbuild 无法在非 RHEL 主机交叉构建 aarch64(Debian rpm
只有 x86_64 构建 arch),aarch64 rpm 需在原生 ARM 机器构建;Linux/ARM
场景已由 deb-arm64 覆盖。
2026-08-28 08:36:55 +08:00
6ee901dc64 chore: 发布脚本 — 全平台构建打包 + gitcode release 资产上传
- scripts/release.sh: 前端构建 → 6 平台交叉编译 (linux/windows/darwin ×
  amd64/arm64) → tar.gz/zip 打包 → SHA256SUMS;子目录隔离同名二进制
- scripts/upload_assets.py: gitcode 两步上传流程
  (upload_url 签名 → urllib PUT 到 OBS),curl 会 401 但 urllib 干净成功
- 首次发布 v0.1.0: 6 个平台安装包 + SHA256SUMS 已上传,下载校验一致
2026-08-28 08:16:58 +08:00
44 changed files with 3929 additions and 65 deletions

3
.gitignore vendored
View File

@ -33,3 +33,6 @@ gui-test-screenshots/
# vendored deps (regenerable; keep out of git) # vendored deps (regenerable; keep out of git)
vendor/ vendor/
.pi-glla/ .pi-glla/
# 发布产物(由 scripts/release.sh 生成)
dist/

View File

@ -72,8 +72,74 @@ rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
``` ```
## 安装
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.2-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo systemctl restart webui4frpc
```
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.2-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo systemctl enable --now webui4frpc
```
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`。
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
### Windows
双击 `webui4frpc-0.1.2-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
- 开始菜单生成启动快捷方式与卸载项
- 自动添加防火墙入站规则
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.2-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.2-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
```bash
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
```
> 该 `.pkg` 未经 Apple 公证(notarization)。首次打开若被 Gatekeeper 拦下,
> 在「系统设置 → 隐私与安全」选择仍要打开。
### 免安装(tar.gz / zip)
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.2-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```
下载后可用 `SHA256SUMS` 校验完整性:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## 运行 ## 运行
从源码构建后直接跑:
```bash ```bash
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data ./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
``` ```

View File

@ -25,6 +25,7 @@ import (
"webui4frpc/internal/process" "webui4frpc/internal/process"
"webui4frpc/internal/render" "webui4frpc/internal/render"
"webui4frpc/internal/store" "webui4frpc/internal/store"
"webui4frpc/internal/version"
) )
func main() { func main() {
@ -132,7 +133,7 @@ func main() {
} }
var ring *cluster.Engine var ring *cluster.Engine
ring = cluster.NewEngine( ring = cluster.NewEngine(
selfID, selfID, *user, *pass, "0.1.0", nil, selfID, selfID, *user, *pass, version.Version, nil,
&cluster.AppHandler{ &cluster.AppHandler{
LoadFn: func() (float64, float64) { LoadFn: func() (float64, float64) {
// NetPct: real NIC saturation from /proc/net/dev deltas vs // NetPct: real NIC saturation from /proc/net/dev deltas vs
@ -174,13 +175,41 @@ func main() {
return err return err
} }
// A re-claim after a forward-centric stop leaves the link flagged // A re-claim after a forward-centric stop leaves the link flagged
// disabled (by RevokeFn); clear it so renderRemote renders the // disabled (by RevokeFn). Do NOT clear that flag here: the ring
// proxy back in. No-op for a fresh claim. // re-claims automatically (startup reconcile re-spawns any owned
_ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, false) // forward whose worker is missing), so clearing it on claim made
// "stopped" un-durable — every restart resurrected a forward the user
// had explicitly stopped, and it then failed forever against a local
// service that was intentionally not running.
//
// The flag is now owned by the two user-facing actions:
// startForward → SetLinkDisabled(false) before submitting the task
// stopForward → SetLinkDisabled(true) before revoking it
// Claim only READS it to decide whether to bring the worker up. A fresh
// claim of a link with no row still starts, because the lookup miss
// below is treated as "not disabled".
disabled := false
if existing, found, err := st.LinkByTriple(tk.Link.Local, tk.Link.Remote, tk.Link.RemotePort); err != nil {
return err
} else if found {
disabled = existing.Disabled
}
// Start (or restart) the per-forward worker for exactly this link. // Start (or restart) the per-forward worker for exactly this link.
// Each forward has its own frpc process (keyed by the forward // Each forward has its own frpc process (keyed by the forward
// triple); restarting only this key leaves sibling forwards' // triple); restarting only this key leaves sibling forwards'
// processes untouched. // processes untouched.
if disabled {
// Mark the entry stopped rather than deleting it. The entry is the
// carrier that keeps the flag travelling around the ring, and
// UpdateTopologyDisabled also clears Active — which is what makes
// this entry inert for OfflineReassign() and the startup
// reconcile, so it is not resurrected later.
if ring != nil {
ring.UpdateTopologyDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
}
log.Printf("ring[%s] claim %s skipped: %s→%s:%d is disabled", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
return nil
}
if tk.Remote.Enabled { if tk.Remote.Enabled {
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort) key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
if _, has := pm.Status(key); has { if _, has := pm.Status(key); has {
@ -200,13 +229,103 @@ func main() {
// stop/start: it wiped sibling forwards sharing the local or remote. // stop/start: it wiped sibling forwards sharing the local or remote.
RevokeFn: func(ctx context.Context, tk *cluster.Task) error { RevokeFn: func(ctx context.Context, tk *cluster.Task) error {
_ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true) _ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
// The links table is node-local: only the node that received the
// stop request has the flag persisted, while the node that OWNS the
// forward is the one holding the worker (and is often a different
// machine). Trusting only the local row meant the owner's copy could
// still read disabled=false. The task now carries the flag, so use it
// as the authority and write it here.
ln, found, err := st.LinkByTriple(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
switch {
case err != nil:
return err
case found:
ln.Disabled = true
// Persist via the same rewrite path the canvas uses, so the flag
// survives a restart on THIS node too.
links, err := st.ListLinks()
if err != nil {
return err
}
for i := range links {
if links[i].Local == ln.Local && links[i].Remote == ln.Remote && links[i].RemotePort == ln.RemotePort {
links[i].Disabled = true
}
}
if err := st.ReplaceLinks(links); err != nil {
return err
}
case tk.Link.Disabled:
// No local row yet (this node never claimed the forward) but the
// task asserts the stop — record it so a later claim on this node
// cannot resurrect the forward.
loc, rem := tk.Local, tk.Remote
links, err := st.ListLinks()
if err != nil {
return err
}
links = append(links, store.Link{
Local: loc.Name, Remote: rem.Name, RemotePort: tk.Link.RemotePort,
OffsetX: tk.Link.OffsetX, OffsetY: tk.Link.OffsetY,
Group: tk.Link.Group, Disabled: true,
})
if err := st.ReplaceLinks(links); err != nil {
return err
}
}
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort) key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
if _, running := pm.Status(key); running { if _, running := pm.Status(key); running {
_ = pm.Stop(key) _ = pm.Stop(key)
} }
// Mark the entry stopped instead of deleting it: the entry carries
// the flag around the ring, and clearing Active keeps it inert for
// the rebalancing paths (OfflineReassign skips inactive entries, and
// the startup reconcile skips disabled forwards), so the worker is
// not re-spawned on the next restart. Deleting it here is what used
// to leave the stop with no carrier at all.
if ring != nil {
ring.UpdateTopologyDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
}
log.Printf("ring[%s] revoked task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort) log.Printf("ring[%s] revoked task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
return nil return nil
}, },
// RestartFn brings an already-claimed forward back after a stop. It
// must NOT run the claim bookkeeping (ClaimFn), which appends links and
// writes topology state: the forward is already established, and a stop
// deliberately keeps its topology entry, so all that is needed is to
// clear the flag and respawn the worker. The engine has already
// re-marked the topology entry enabled before calling this.
RestartFn: func(ctx context.Context, tk *cluster.Task) error {
// Clear the stop on this node's own store, using the same path the
// start handler uses, so the local copy cannot disagree with the
// ring and block a later restart.
if ln, found, err := st.LinkByTriple(tk.Link.Local, tk.Link.Remote, tk.Link.RemotePort); err != nil {
return err
} else if found && ln.Disabled {
links, err := st.ListLinks()
if err != nil {
return err
}
for i := range links {
if links[i].Local == ln.Local && links[i].Remote == ln.Remote && links[i].RemotePort == ln.RemotePort {
links[i].Disabled = false
}
}
if err := st.ReplaceLinks(links); err != nil {
return err
}
}
if tk.Remote.Enabled {
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
if _, has := pm.Status(key); has {
_ = pm.Restart(key)
} else {
_ = pm.Start(key)
}
}
log.Printf("ring[%s] restarted %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
return nil
},
}, },
ringTransport.SendTo(func(nodeID string) string { ringTransport.SendTo(func(nodeID string) string {
if ring == nil { if ring == nil {
@ -231,10 +350,24 @@ func main() {
// left does NOT auto-rejoin. // left does NOT auto-rejoin.
ring.SetPeerPersist(func(peersJSON string) error { return st.SetClusterPeers(peersJSON) }) ring.SetPeerPersist(func(peersJSON string) error { return st.SetClusterPeers(peersJSON) })
// Re-apply local store group labels onto the ring topology after each // Re-apply local store state onto the ring topology after each state
// state adoption. Without this, group changes made via HTTP handlers // adoption, and learn the ring's disabled decisions back into the store.
//
// Without the group half, group changes made via HTTP handlers
// (POST /forwards/assign) are overwritten by the next e.state = tk.State // (POST /forwards/assign) are overwritten by the next e.state = tk.State
// and never propagate to other nodes. // and never propagate.
//
// The disabled half makes the RING authoritative for stop/start. That is the
// only source all members can agree on: the links table is a per-node copy, so
// treating it as authoritative let two nodes disagree indefinitely (observed
// live: one node reading disabled=1 while the owner still read 0 and kept its
// worker running). This is a write-through, not a listener — it runs on every
// adoption, so any peer's decision lands here within a token cycle.
//
// Ordering matters in one direction only: the engine has already re-applied
// this node's not-yet-confirmed local decisions onto the adopted state (see
// reconcileLocalDisabled), so reading the ring here can never clobber an
// action the user just took on this node.
ring.SetTopologySync(func() { ring.SetTopologySync(func() {
links, err := st.ListLinks() links, err := st.ListLinks()
if err != nil { if err != nil {
@ -243,6 +376,16 @@ func main() {
for _, ln := range links { for _, ln := range links {
ring.UpdateTopologyGroup(ln.Local, ln.Remote, ln.RemotePort, ln.Group) ring.UpdateTopologyGroup(ln.Local, ln.Remote, ln.RemotePort, ln.Group)
} }
for _, te := range ring.State().TopologyList() {
disabled, known := ring.TopologyDisabled(te.Local.Name, te.Remote.Name, te.Link.RemotePort)
if !known {
continue
}
if ln, found, err := st.LinkByTriple(te.Local.Name, te.Remote.Name, te.Link.RemotePort); err == nil && found && ln.Disabled == disabled {
continue // store already agrees with the ring
}
_ = st.ReconcileLinkDisabled(te.Local.Name, te.Remote.Name, te.Link.RemotePort, disabled)
}
}) })
h := &httpapi.Handler{ h := &httpapi.Handler{
@ -436,6 +579,16 @@ func main() {
if t.OwnerID != selfID { if t.OwnerID != selfID {
continue continue
} }
// A forward the user stopped must stay stopped: skip it here so a
// restart does not re-spawn its worker. The ClaimFn enforces the
// same rule (belt and braces — this also avoids a pointless
// claim→skip round trip per disabled forward on every boot).
if ln, found, err := st.LinkByTriple(t.Local.Name, t.Remote.Name, t.Link.RemotePort); err != nil {
log.Printf("ring[%s] reconcile: lookup %s→%s:%d: %v", ring.ID, t.Local.Name, t.Remote.Name, t.Link.RemotePort, err)
continue
} else if found && ln.Disabled {
continue
}
key := process.WorkerKey(t.Local.Name, t.Remote.Name, t.Link.RemotePort) key := process.WorkerKey(t.Local.Name, t.Remote.Name, t.Link.RemotePort)
if _, has := pm.Status(key); has { if _, has := pm.Status(key); has {
continue // worker already running continue // worker already running
@ -533,7 +686,7 @@ func main() {
// CreateCluster so the node is at least functional as standalone. // CreateCluster so the node is at least functional as standalone.
func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) { func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) {
selfNode := ring.State().Nodes[0] selfNode := ring.State().Nodes[0]
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: joinKey} ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: joinKey}
deadline := time.NewTimer(5 * time.Minute) deadline := time.NewTimer(5 * time.Minute)
defer deadline.Stop() defer deadline.Stop()
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)
@ -593,7 +746,7 @@ func retryRejoinCached(ctx context.Context, ring *cluster.Engine, peersJSON stri
return return
} }
for _, p := range peers { for _, p := range peers {
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: p.Key} ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: p.Key}
jc, cancel := context.WithTimeout(ctx, 8*time.Second) jc, cancel := context.WithTimeout(ctx, 8*time.Second)
err := ring.JoinRing(jc, p.Addr, ji) err := ring.JoinRing(jc, p.Addr, ji)
cancel() cancel()

91
internal/cluster/debug.go Normal file
View File

@ -0,0 +1,91 @@
package cluster
import (
"log"
"os"
"sync/atomic"
)
// logf is the package's logging seam: every cluster log line funnels through it
// so the debug gate in debug.go has a single place to hook.
func logf(format string, args ...any) { log.Printf(format, args...) }
// Token rotation is the ring's heartbeat: with a 2s round delay it fires
// continuously, and the default three log lines per round
// ("OnToken cycle=N" / "forward cycle=N to X" / "token-send ... -> 200")
// carry no information — no cycle number, address, timing or payload ever
// changes in the steady state. Measured on the live 3-node cluster that was
// ~510k lines/day on one node (3.5M lines in a week), which drowns every real
// event in the journal and fills the disk for a signal that is already
// available in structured form via GET /api/manager/cluster/ring (cycle,
// lastSync, roundDelayMs, node aliveness).
//
// So the steady-state lines are demoted to a debug level, off by default and
// enabled with W4F_DEBUG=token (or 1/all/true for every debug line). Failure
// paths are NOT demoted: a send error, a stale token, a timeout or a leader
// change is exactly what someone is grepping for, and losing those to a quiet
// default would be a bad trade.
const debugEnv = "W4F_DEBUG"
// debugToken logs a per-token-round heartbeat line. Suppressed unless
// W4F_DEBUG selects "token" (or a catch-all value).
func debugToken(format string, args ...any) {
if debugTokenOn.Load() {
logf(format, args...)
}
}
// debugAll logs an ad-hoc diagnostic line. Suppressed unless W4F_DEBUG is set
// to a catch-all value (1/all/true/*).
func debugAll(format string, args ...any) {
if debugAllOn.Load() {
logf(format, args...)
}
}
var (
debugTokenOn atomic.Bool
debugAllOn atomic.Bool
)
func init() { ReloadDebug() }
// ReloadDebug re-reads W4F_DEBUG. Called once at init so tests can flip it
// without restarting, and available at runtime for an operator who wants to
// watch the ring without a redeploy.
func ReloadDebug() {
v := os.Getenv(debugEnv)
switch normalized := normalizeDebugValue(v); normalized {
case "token":
debugTokenOn.Store(true)
debugAllOn.Store(false)
case "all":
debugTokenOn.Store(true)
debugAllOn.Store(true)
default:
debugTokenOn.Store(false)
debugAllOn.Store(false)
}
}
func normalizeDebugValue(v string) string {
// Compare case-insensitively without pulling in strings just for this.
out := make([]rune, 0, len(v))
for _, r := range v {
if r >= 'A' && r <= 'Z' {
r += 'a' - 'A'
}
out = append(out, r)
}
s := string(out)
switch s {
case "":
return ""
case "token", "tokens", "ring":
return "token"
}
// Any other non-empty value is a deliberate request for more output, so it
// is treated as a catch-all rather than silently muting the operator who
// set it. "0" lands here too: it was asked for, so honour it.
return "all"
}

View File

@ -0,0 +1,108 @@
package cluster
import (
"bytes"
"log"
"os"
"strings"
"testing"
)
// captureLog redirects the standard logger into a buffer for the duration of
// fn and returns what was written.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
orig := log.Writer()
origFlags := log.Flags()
log.SetOutput(&buf)
log.SetFlags(0)
defer func() {
log.SetOutput(orig)
log.SetFlags(origFlags)
}()
fn()
return buf.String()
}
func TestDebugTokenSuppressedByDefault(t *testing.T) {
os.Unsetenv(debugEnv)
ReloadDebug()
out := captureLog(t, func() {
debugToken("ring[%s] OnToken cycle=%d", "node:7500", 42)
debugToken("ring[%s] forward cycle=%d to %s", "node:7500", 42, "next:7500")
debugToken("token-send %s: size=%dB elapsed=%v -> %d", "next:7500", 5605, "170ms", 200)
})
if out != "" {
t.Fatalf("steady-state token lines logged with W4F_DEBUG unset: %q", out)
}
}
func TestDebugTokenEnabledByEnv(t *testing.T) {
for _, v := range []string{"token", "TOKEN", "tokens", "ring", "1", "true", "yes", "all", "*", "yes-please"} {
t.Run(v, func(t *testing.T) {
t.Setenv(debugEnv, v)
ReloadDebug()
if !debugTokenOn.Load() {
t.Fatalf("W4F_DEBUG=%q should enable the token heartbeat", v)
}
out := captureLog(t, func() {
debugToken("ring[%s] OnToken cycle=%d", "node:7500", 7)
})
if !strings.Contains(out, "OnToken cycle=7") {
t.Fatalf("W4F_DEBUG=%q: expected the line to be logged, got %q", v, out)
}
})
}
}
// The whole point of the gate is to shrink the journal, so the failure paths
// must stay loud without any env var — losing a send error to a quiet default
// would be a bad trade.
func TestFailurePathsStayLoud(t *testing.T) {
os.Unsetenv(debugEnv)
ReloadDebug()
out := captureLog(t, func() {
logf("token-send %s: size=%dB elapsed=%v err=%v", "down:7500", 10, "5ms", "connection refused")
})
if !strings.Contains(out, "connection refused") {
t.Fatalf("send errors must never be gated: got %q", out)
}
}
func TestDebugAllOffForTokenOnly(t *testing.T) {
t.Setenv(debugEnv, "token")
ReloadDebug()
if !debugTokenOn.Load() {
t.Fatal("token level should enable debugToken")
}
if debugAllOn.Load() {
t.Fatal("token level must not enable the catch-all debugAll")
}
out := captureLog(t, func() { debugAll("scratch diagnostic") })
if out != "" {
t.Fatalf("debugAll should stay off at token level, got %q", out)
}
}
func TestNormalizeDebugValue(t *testing.T) {
cases := map[string]string{
"": "",
"token": "token",
"TOKEN": "token",
"Ring": "token",
"1": "all",
"true": "all",
"ALL": "all",
"*": "all",
"anything": "all", // unrecognised but deliberate → don't silence it
"0": "all", // 0 is still a deliberate request for output
}
for in, want := range cases {
if got := normalizeDebugValue(in); got != want {
t.Errorf("normalizeDebugValue(%q) = %q, want %q", in, got, want)
}
}
}

View File

@ -62,6 +62,12 @@ type Task struct {
// owning node cancels it (stop worker, drop from topology). Reuses the // owning node cancels it (stop worker, drop from topology). Reuses the
// same publish channel as creation (round-1 inject, round-2 apply). // same publish channel as creation (round-1 inject, round-2 apply).
Revoke bool `json:"revoke,omitempty"` Revoke bool `json:"revoke,omitempty"`
// Restart marks a RE-ENABLE task: the forward is already claimed and its
// topology entry still exists (a stop keeps the entry as the flag's
// carrier), so the creation path would dedupe the submission and the
// duplicate-claim guard would discard it. The owner applies this one
// unconditionally: re-mark the entry enabled and (re)spawn the worker.
Restart bool `json:"restart,omitempty"`
// RemoveNode: node ID to remove from the ring; the node self-removes when // RemoveNode: node ID to remove from the ring; the node self-removes when
// the command reaches it via the token. // the command reaches it via the token.
RemoveNode string `json:"removeNode,omitempty"` RemoveNode string `json:"removeNode,omitempty"`
@ -333,10 +339,15 @@ func (s *State) AddTopology(t *Task, ownerID string) *TopoEntry {
if s.Topology == nil { if s.Topology == nil {
s.Topology = map[string]*TopoEntry{} s.Topology = map[string]*TopoEntry{}
} }
// Active mirrors the task's disabled flag rather than being unconditionally
// true. A claim can legitimately carry a stopped forward (e.g. a node
// re-claiming from a departed peer), and forcing Active=true there would
// resurrect it: OfflineReassign only re-queues Active entries, and every
// reader that filters on Active would treat it as running again.
e := &TopoEntry{ e := &TopoEntry{
TaskID: t.ID, OwnerID: ownerID, TaskID: t.ID, OwnerID: ownerID,
Local: t.Local, Remote: t.Remote, Link: t.Link, Local: t.Local, Remote: t.Remote, Link: t.Link,
Active: true, Active: !t.Link.Disabled,
} }
s.Topology[t.ID] = e s.Topology[t.ID] = e
return e return e
@ -408,6 +419,39 @@ func (s *State) ForwardsOwnedBy(owner string) []*TopoEntry {
// (local, remote, port) triple. Returns true if found. The updated entry // (local, remote, port) triple. Returns true if found. The updated entry
// propagates to all nodes via the next token cycle — group changes sync // propagates to all nodes via the next token cycle — group changes sync
// through the ring without a dedicated command. // through the ring without a dedicated command.
// UpdateTopologyDisabled sets the disabled flag on the topology entry matching
// the forward's natural key. Mirror of UpdateTopologyGroup: it makes a locally
// decided stop/start part of the topology so the next token cycle carries it to
// every other member (the alternative — a one-shot revoke task addressed at the
// owner — only converges if that owner happens to be online right then).
//
// Returns false when the forward is not in the topology, which is not an error:
// a stopped forward may legitimately have no entry yet.
func (s *State) UpdateTopologyDisabled(local, remote string, port int, disabled bool) bool {
for _, e := range s.Topology {
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
e.Link.Disabled = disabled
// Keep Active consistent with the flag so readers that key off it
// (status page, load accounting) agree with Link.Disabled.
e.Active = !disabled
return true
}
}
return false
}
// TopologyDisabled returns the disabled flag the cluster currently holds for a
// forward, and whether such an entry exists. Used by the adoption path to learn
// a peer's decision into the local store.
func (s *State) TopologyDisabled(local, remote string, port int) (bool, bool) {
for _, e := range s.Topology {
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
return e.Link.Disabled, true
}
}
return false, false
}
func (s *State) UpdateTopologyGroup(local, remote string, port int, group string) bool { func (s *State) UpdateTopologyGroup(local, remote string, port int, group string) bool {
for _, e := range s.Topology { for _, e := range s.Topology {
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port { if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {

View File

@ -0,0 +1,157 @@
package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
// staleTokenWith copies the engine's current state but rewrites the given
// forward's disabled flag, standing in for a token a neighbour captured before
// this node's decision.
func staleTokenWith(eng *Engine, local, remote string, port int, disabled bool) State {
s := eng.state
out := State{
LeaderID: s.LeaderID,
Nodes: s.Nodes,
Cycle: s.Cycle,
Topology: map[string]*TopoEntry{},
}
for id, e := range s.Topology {
cp := *e
if cp.Local.Name == local && cp.Remote.Name == remote && cp.Link.RemotePort == port {
cp.Link.Disabled = disabled
cp.Active = !disabled
}
out.Topology[id] = &cp
}
return out
}
// TestLocalDisableSurvivesAdoption is the regression test for the wipe that made
// the ring's authority unachievable.
//
// OnToken/AdoptState replace e.state wholesale. A stop decided between two token
// cycles was therefore erased by the next adoption whenever the incoming token
// had been captured before the decision — so the flag never reached the other
// members and the forward kept running. Written as a probe BEFORE the fix: it
// reported "after adopting a stale token: known=true disabled=false".
func TestLocalDisableSurvivesAdoption(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
t.Fatal(err)
}
// Local decision: stop it.
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
if d, _ := eng.TopologyDisabled("web", "frps1", 18081); !d {
t.Fatal("precondition: the local stop should be visible")
}
// A token captured BEFORE the decision arrives.
stale := staleTokenWith(eng, "web", "frps1", 18081, false)
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: stale, SentAt: 2}); err != nil {
t.Fatal(err)
}
// Assert on the STATE, not the TopologyDisabled() accessor: the accessor
// short-circuits to the pending local decision, so it would report the stop
// even when the state about to be forwarded still says "enabled". What
// matters is that the token this node sends on carries the decision.
assertForwardedDisabled(t, eng, "web", "frps1", 18081, true)
}
// assertForwardedDisabled checks the disabled value a DOWNSTREAM node would see
// in the token this engine forwards — the value that actually propagates.
func assertForwardedDisabled(t *testing.T, eng *Engine, local, remote string, port int, want bool) {
t.Helper()
// Model one hop: a fresh engine adopts exactly what this one would send.
peer := newTestEngine("peer", false)
peer.AdoptState(eng.state)
got, known := peer.TopologyDisabled(local, remote, port)
if !known {
t.Fatalf("peer learned no entry for %s→%s:%d", local, remote, port)
}
if got != want {
t.Fatalf("the forwarded token carries disabled=%v, want %v — the decision did not "+
"propagate, so the other members would keep the old state", got, want)
}
}
// TestLocalReEnableSurvivesAdoption: a re-enable needs the same protection. The
// value false is easy to overlook — nothing "looks" stopped, yet losing it just
// as silently strands the forward in the stopped state.
func TestLocalReEnableSurvivesAdoption(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
t.Fatal(err)
}
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
eng.UpdateTopologyDisabled("web", "frps1", 18081, false)
// A token that still says disabled (captured mid-stop) arrives.
mid := staleTokenWith(eng, "web", "frps1", 18081, true)
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: mid, SentAt: 2}); err != nil {
t.Fatal(err)
}
assertForwardedDisabled(t, eng, "web", "frps1", 18081, false)
}
// TestConfirmedDecisionStopsBeingAsserted: once the ring reports the same value
// the decision is confirmed everywhere, so this node must stop overriding —
// otherwise it would fight a later decision made elsewhere.
func TestConfirmedDecisionStopsBeingAsserted(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
t.Fatal(err)
}
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
if len(eng.localDisabled) != 1 {
t.Fatalf("a pending decision should be tracked, got %v", eng.localDisabled)
}
// The ring comes back agreeing.
agreed := staleTokenWith(eng, "web", "frps1", 18081, true)
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: agreed, SentAt: 2}); err != nil {
t.Fatal(err)
}
if len(eng.localDisabled) != 0 {
t.Fatalf("a confirmed decision must stop being asserted, still tracking %v", eng.localDisabled)
}
// A peer now decides the opposite; this node must follow the ring.
peerSaysEnabled := staleTokenWith(eng, "web", "frps1", 18081, false)
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 3, State: peerSaysEnabled, SentAt: 3}); err != nil {
t.Fatal(err)
}
if d, _ := eng.TopologyDisabled("web", "frps1", 18081); d {
t.Fatal("this node kept overriding a peer's later decision; the ring is not authoritative")
}
}
// TestLocalDecisionsDoNotAccumulate: the tracking map is swept against the live
// topology, so a long-lived cluster cannot grow it without bound.
func TestLocalDecisionsDoNotAccumulate(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
t.Fatal(err)
}
// Decisions for forwards this node has never seen.
eng.UpdateTopologyDisabled("ghost1", "frps1", 1, true)
eng.UpdateTopologyDisabled("ghost2", "frps1", 2, true)
if len(eng.localDisabled) != 2 {
t.Fatalf("expected 2 tracked decisions, got %d", len(eng.localDisabled))
}
// A cycle runs: neither ghost is in the topology, so both are swept.
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state, SentAt: 2}); err != nil {
t.Fatal(err)
}
if len(eng.localDisabled) != 0 {
t.Fatalf("decisions for forwards outside the topology must be swept, got %v", eng.localDisabled)
}
}

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"strconv"
"sync" "sync"
"time" "time"
@ -18,6 +19,11 @@ import (
type Handler interface { type Handler interface {
Claim(ctx context.Context, tk *Task) error Claim(ctx context.Context, tk *Task) error
Revoke(ctx context.Context, tk *Task) error Revoke(ctx context.Context, tk *Task) error
// Restart re-enables an already-claimed forward and brings its worker back.
// Needed because a stop keeps the topology entry, which makes both the
// creation path (idempotency guard) and the duplicate-claim guard refuse to
// re-create an existing forward.
Restart(ctx context.Context, tk *Task) error
RuntimeLoad() Load RuntimeLoad() Load
} }
@ -50,6 +56,24 @@ type Engine struct {
// group changes made via HTTP handlers between token cycles are overwritten // group changes made via HTTP handlers between token cycles are overwritten
// by the next state adoption and never propagate to other nodes. // by the next state adoption and never propagate to other nodes.
topologySync func() topologySync func()
// localDisabled records this node's own enabled/disabled decisions that have
// not yet been confirmed by the ring. It is what makes the RING authoritative
// without losing a decision that was made locally and has not yet had a
// chance to reach the other members.
//
// The problem it solves: OnToken/AdoptState do `e.state = tk.State`, a
// wholesale replacement. A stop decided between two token cycles therefore
// disappears on the very next adoption if that token was captured before the
// decision — the flag never reaches the rest of the ring, and the forward
// keeps running. (Verified with a probe before writing this: adopting a
// stale token flipped disabled back to false.)
//
// A key stays in this map until an adoption reports it disabled, at which
// point the whole cluster agrees and the key is dropped. Keys for forwards
// that vanish from the topology are swept on every update, so the map cannot
// grow without bound. The value false is what keeps a RE-ENABLE alive for the
// same reason a stop needs protecting.
localDisabled map[string]bool
state State state State
// myAddr maps our Node ID to the address peers dial. // myAddr maps our Node ID to the address peers dial.
@ -182,7 +206,9 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
if tk.SentAt > e.lastTokenAt { if tk.SentAt > e.lastTokenAt {
e.lastTokenAt = tk.SentAt e.lastTokenAt = tk.SentAt
} }
log.Printf("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle) // Per-round heartbeat — steady state, no information. See debug.go: this
// fired ~2x/second and dominated the journal on every node.
debugToken("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
// Parallel rhythm timer: operations run while the pace clock ticks. // Parallel rhythm timer: operations run while the pace clock ticks.
// Delay scales with alive node count (more nodes → lower per-hop delay, // Delay scales with alive node count (more nodes → lower per-hop delay,
@ -229,6 +255,11 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// Re-apply local store overrides (group labels, disabled flags) onto // Re-apply local store overrides (group labels, disabled flags) onto
// the freshly adopted topology so they survive state adoption and // the freshly adopted topology so they survive state adoption and
// propagate to all nodes via the next token forward. // propagate to all nodes via the next token forward.
// Re-assert decisions this node made locally that the ring has not yet
// confirmed, BEFORE the host's topology sync runs: the sync reads
// TopologyDisabled to learn the cluster's view, so the local decision must
// already be applied or a stop could be reported as "enabled" and dropped.
e.reconcileLocalDisabled()
if e.topologySync != nil { if e.topologySync != nil {
e.topologySync() e.topologySync()
} }
@ -375,6 +406,24 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
} }
continue // owned elsewhere — ride to the owner continue // owned elsewhere — ride to the owner
} }
if t.Restart {
// Owner-directed, exactly like a revoke: only the node holding the
// forward may act. Selecting it anywhere else would let a non-owner
// spawn a worker for somebody else's forward (observed live: a
// non-owner logged "restarted t4" and ran the forward itself).
//
// A restart whose forward is already gone is a no-op consumed by the
// lowest node, so a task can never ride forever if its owner
// departed before seeing it.
if owner := e.state.TopologyOwner(t); owner == e.ID {
target = t
break
} else if owner == "" && selfIsLowest {
target = t // forward gone — consume and re-enable nothing
break
}
continue // owned elsewhere — ride to the owner
}
if selfIsLowest { if selfIsLowest {
target = t // generic forward create — lowest-load claim target = t // generic forward create — lowest-load claim
break break
@ -388,18 +437,37 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
break break
} }
if claimed.Revoke { if claimed.Revoke {
if e.state.RemoveTopology(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort) { // A user-requested stop is recorded as a FLAG on the topology entry,
// not as a removal. The entry is the carrier that makes the decision
// travel: TopoEntry.Link is a full store.Link and State rides the
// token every cycle, so keeping the entry is what lets a stop reach
// every member — including a node that was offline when the stop was
// issued. Removing the entry instead left the flag nowhere to live,
// which is why a stop used to converge only if the owner happened to
// be online to receive a one-shot revoke task.
//
// Marking Active=false is also what makes the entry inert for the
// rebalancing paths: OfflineReassign() skips inactive entries, and
// the startup reconcile skips them too, so a stopped forward is
// neither re-queued on a node departure nor re-claimed on a restart.
//
// Handler.Revoke still runs unconditionally — it is what actually
// stops the per-forward worker, and it must run even when there was no
// entry to mark (gating it on RemoveTopology()'s result made stops of
// already-absent forwards silently do nothing, so their workers ran
// forever: observed live as thousands of connection-refused lines
// against a local service that was intentionally down).
marked := e.state.UpdateTopologyDisabled(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort, true)
if e.Handler != nil { if e.Handler != nil {
if err := e.Handler.Revoke(ctx, claimed); err != nil { if err := e.Handler.Revoke(ctx, claimed); err != nil {
log.Printf("ring[%s] revoke %s: %v", e.ID, claimed.ID, err) log.Printf("ring[%s] revoke %s: %v", e.ID, claimed.ID, err)
} }
} }
if e.Log != nil { if marked && e.Log != nil {
_, _ = e.Log.Append(e.ID, LogForwardRemove, map[string]any{ _, _ = e.Log.Append(e.ID, LogForwardStop, map[string]any{
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name, "taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
}) })
} }
}
continue continue
} }
if claimed.RemoveNode != "" && claimed.RemoveNode == e.ID { if claimed.RemoveNode != "" && claimed.RemoveNode == e.ID {
@ -465,12 +533,46 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
// different node. Safe for OfflineReassign: that path deletes the // different node. Safe for OfflineReassign: that path deletes the
// topology entry BEFORE re-queueing, so TopologyOwner returns "" and // topology entry BEFORE re-queueing, so TopologyOwner returns "" and
// the legitimate re-claim passes through. // the legitimate re-claim passes through.
//
// A RESTART task is the deliberate exception this guard must not eat: it
// exists precisely to re-activate a forward whose entry is still present
// (a stop keeps the entry as the flag's carrier), so applying the guard
// would swallow every re-enable and leave the forward stopped forever.
if !claimed.Restart {
if owner := e.state.TopologyOwner(claimed); owner != "" { if owner := e.state.TopologyOwner(claimed); owner != "" {
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s", log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name, e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
claimed.Link.RemotePort, owner) claimed.Link.RemotePort, owner)
continue continue
} }
}
if claimed.Restart {
// Only reached when this node owns the forward (or it is already gone
// and we are the fallback consumer — see the selection loop).
owner := e.state.TopologyOwner(claimed)
e.state.UpdateTopologyDisabled(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort, false)
if owner != e.ID {
// Forward vanished before we got here: nothing to re-enable.
log.Printf("ring[%s] discard restart %s: no owner", e.ID, claimed.ID)
continue
}
if e.Handler != nil {
if err := e.Handler.Restart(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
log.Printf("ring[%s] restart %s failed: %v", e.ID, claimed.ID, err)
break
}
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogForwardStart, map[string]any{
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
})
}
// Logging is the app's job (RestartFn reports the actual worker
// outcome); the ring layer stays quiet so one restart is one line
// rather than two identical ones.
continue
}
if e.Handler != nil { if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil { if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed e.state.PendingTasks[claimed.ID] = claimed
@ -724,6 +826,11 @@ func (e *Engine) AdoptState(s State) {
for id, t := range kept { for id, t := range kept {
e.state.PendingTasks[id] = t e.state.PendingTasks[id] = t
} }
// Re-assert decisions this node made locally that the ring has not yet
// confirmed, BEFORE the host's topology sync runs: the sync reads
// TopologyDisabled to learn the cluster's view, so the local decision must
// already be applied or a stop could be reported as "enabled" and dropped.
e.reconcileLocalDisabled()
if e.topologySync != nil { if e.topologySync != nil {
e.topologySync() e.topologySync()
} }
@ -792,6 +899,14 @@ func (e *Engine) RemoveNode(nodeID string) *Task {
return e.state.AddRemoveNode(nodeID) return e.state.AddRemoveNode(nodeID)
} }
// RemoveTopologyEntry drops the active topology entry for a forward identified
// by its natural key. Exposed so the app's claim path can retire an entry it
// refuses to serve (see ClaimFn: a disabled forward must stop looking active,
// otherwise the startup reconcile keeps re-claiming it on every restart).
func (e *Engine) RemoveTopologyEntry(local, remote string, port int) bool {
return e.state.RemoveTopology(local, remote, port)
}
// RevokeTask publishes a revocation for an established forward through the // RevokeTask publishes a revocation for an established forward through the
// same token channel; the owning node stops the worker and drops topology. // same token channel; the owning node stops the worker and drops topology.
func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.Link) *Task { func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.Link) *Task {
@ -799,14 +914,87 @@ func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.L
} }
func (e *Engine) SubmitTask(local store.Local, remote store.Remote, link store.Link) *Task { func (e *Engine) SubmitTask(local store.Local, remote store.Remote, link store.Link) *Task {
if e.HasTask(local.Name, remote.Name, link.RemotePort) { // Guard on an ACTIVE task only. A topology entry that is merely marked
// disabled is kept around as the carrier for the stop flag, so counting it
// here would make this a permanent no-op and a stopped forward could never
// be started again.
if e.HasActiveTask(local.Name, remote.Name, link.RemotePort) {
return nil return nil
} }
return e.state.AddPending(local, remote, link) return e.state.AddPending(local, remote, link)
} }
// HasTask reports whether a forward with the same local/remote/remotePort is // HasTopologyEntry reports whether the forward has a topology entry (whether or
// already pending or active in the topology (idempotency guard for resaves). // not it is marked disabled). A stop keeps the entry as the flag's carrier, so
// "has an entry" is what distinguishes "already claimed by someone" from "never
// submitted" — the distinction startForward needs.
func (e *Engine) HasTopologyEntry(local, remote string, port int) bool {
_, found := e.state.TopologyDisabled(local, remote, port)
return found
}
// SubmitRestart publishes a task asking the current owner of an already-claimed
// forward to bring its worker back up.
//
// Neither of the existing channels can express "restart what already exists":
// SubmitTask is guarded by HasActiveTask and the entry is still present, so it
// dedupes; and the claim path drops any task for a forward that already has an
// owner (its defence against duplicate-claim collisions). Re-enabling a stopped
// forward therefore needs its own task kind, which the owner applies without
// re-running the claim bookkeeping.
func (e *Engine) SubmitRestart(local store.Local, remote store.Remote, link store.Link) *Task {
// The task must not carry the stop: the whole point is to re-enable.
link.Disabled = false
t := &Task{
ID: e.state.NextTaskID(),
Local: local,
Remote: remote,
Link: link,
Created: time.Now().Unix(),
Restart: true,
}
if e.state.PendingTasks == nil {
e.state.PendingTasks = map[string]*Task{}
}
e.state.PendingTasks[t.ID] = t
return t
}
// HasActiveTask reports whether a forward with this key is genuinely in flight
// or running: a non-revocation pending task, or a topology entry that is not
// marked disabled.
//
// It is deliberately narrower than HasTask. Under mark-don't-remove a stopped
// forward keeps its topology entry (that entry is what carries the flag between
// nodes), so "an entry exists" no longer means "this forward is active".
// SubmitTask must use THIS predicate, otherwise starting a stopped forward —
// startForward() publishes the enable and then calls SubmitTask — would be
// swallowed by its own idempotency guard.
func (e *Engine) HasActiveTask(local, remote string, port int) bool {
for _, t := range e.state.PendingList() {
if t.Revoke {
continue // a revocation is the opposite of an active forward
}
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
return true
}
}
for _, t := range e.state.TopologyList() {
if t.Link.Disabled {
continue // stopped; the entry is only a flag carrier
}
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
return true
}
}
return false
}
// HasTask reports whether ANY record for this forward exists — a pending task
// (including an in-flight revocation) or a topology entry (including one marked
// disabled). This is the "have we already handled this key" question, used to
// avoid re-issuing work on repeated canvas saves. For "is it actually running"
// use HasActiveTask instead.
func (e *Engine) HasTask(local, remote string, port int) bool { func (e *Engine) HasTask(local, remote string, port int) bool {
for _, t := range e.state.PendingList() { for _, t := range e.state.PendingList() {
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port { if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
@ -828,6 +1016,78 @@ func (e *Engine) UpdateTopologyGroup(local, remote string, port int, group strin
return e.state.UpdateTopologyGroup(local, remote, port, group) return e.state.UpdateTopologyGroup(local, remote, port, group)
} }
// UpdateTopologyDisabled marks a forward enabled/disabled in the topology so
// the decision rides the next token cycle to every member. Paired with
// TopologyDisabled, which the adoption hook uses to learn peers' decisions.
//
// The decision is also remembered locally until the ring confirms it, because
// adoption replaces the whole state: a token captured before this call would
// otherwise wash the decision out on the next cycle and it would never reach
// the other members. See the localDisabled field.
func (e *Engine) UpdateTopologyDisabled(local, remote string, port int, disabled bool) bool {
if e.localDisabled == nil {
e.localDisabled = map[string]bool{}
}
e.localDisabled[disableKey(local, remote, port)] = disabled
return e.state.UpdateTopologyDisabled(local, remote, port, disabled)
}
// TopologyDisabled reports the cluster's view of a forward's disabled flag.
// The bool is false when the forward has no topology entry (nothing to learn).
//
// A pending local decision takes precedence over the adopted state: it has not
// had a chance to reach the other members yet, and reporting the adopted value
// would make the local store (and the status page) disagree with the user's
// most recent action.
func (e *Engine) TopologyDisabled(local, remote string, port int) (bool, bool) {
if d, pending := e.localDisabled[disableKey(local, remote, port)]; pending {
return d, true
}
return e.state.TopologyDisabled(local, remote, port)
}
// reconcileLocalDisabled re-asserts this node's not-yet-confirmed disabled
// decisions onto the freshly adopted state. Called right after e.state is
// replaced, and paired with reconcileTopologySync (which pushes those decisions
// out to the ring).
//
// A decision is dropped once the adopted state reports the SAME value: at that
// point every member agrees and there is nothing left to protect. Entries whose
// forward no longer exists in the topology are dropped too, so the map tracks
// only live disagreements.
func (e *Engine) reconcileLocalDisabled() {
if len(e.localDisabled) == 0 {
return
}
for id, te := range e.state.Topology {
_ = id
k := disableKey(te.Local.Name, te.Remote.Name, te.Link.RemotePort)
want, pending := e.localDisabled[k]
if !pending {
continue
}
if te.Link.Disabled == want {
// The ring caught up (or agreed independently): stop tracking.
delete(e.localDisabled, k)
continue
}
// Still divergent: keep asserting our decision onto the adopted state.
te.Link.Disabled = want
te.Active = !want
}
// Forget decisions for forwards that left the topology entirely — otherwise
// a long-lived cluster would accumulate dead keys.
live := make(map[string]struct{}, len(e.state.Topology))
for _, te := range e.state.Topology {
live[disableKey(te.Local.Name, te.Remote.Name, te.Link.RemotePort)] = struct{}{}
}
for k := range e.localDisabled {
if _, ok := live[k]; !ok {
delete(e.localDisabled, k)
}
}
}
// IsLeader reports whether this node is the current ring leader. // IsLeader reports whether this node is the current ring leader.
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID } func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
@ -850,6 +1110,14 @@ func (e *Engine) SetPeerPersist(fn func(peersJSON string) error) { e.peerPersist
// HTTP handlers are overwritten by the next e.state = tk.State. // HTTP handlers are overwritten by the next e.state = tk.State.
func (e *Engine) SetTopologySync(fn func()) { e.topologySync = fn } func (e *Engine) SetTopologySync(fn func()) { e.topologySync = fn }
// disableKey builds the map key for a forward's disabled decision: the natural
// triple (local, remote, remotePort), which is how every other part of the code
// identifies a forward. A NUL separator keeps it unambiguous for names that
// could otherwise collide across the boundaries.
func disableKey(local, remote string, port int) string {
return local + "\x00" + remote + "\x00" + strconv.Itoa(port)
}
// persistPeers extracts all alive peers (addr + nodeKey, excluding self) // persistPeers extracts all alive peers (addr + nodeKey, excluding self)
// from the current ring state and persists them via the peerPersist callback. // from the current ring state and persists them via the peerPersist callback.
// Called on every token cycle (OnToken) and on AdoptState so a crashed node // Called on every token cycle (OnToken) and on AdoptState so a crashed node

View File

@ -11,6 +11,7 @@ type fakeHandler struct {
load Load load Load
claim func(ctx context.Context, tk *Task) error claim func(ctx context.Context, tk *Task) error
revoke func(ctx context.Context, tk *Task) error revoke func(ctx context.Context, tk *Task) error
restart func(ctx context.Context, tk *Task) error
} }
func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error { func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
@ -20,6 +21,13 @@ func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
return nil return nil
} }
func (h *fakeHandler) Restart(ctx context.Context, tk *Task) error {
if h.restart != nil {
return h.restart(ctx, tk)
}
return nil
}
func (h *fakeHandler) RuntimeLoad() Load { return h.load } func (h *fakeHandler) RuntimeLoad() Load { return h.load }
func (h *fakeHandler) Claim(ctx context.Context, tk *Task) error { func (h *fakeHandler) Claim(ctx context.Context, tk *Task) error {
if h.claim != nil { if h.claim != nil {

View File

@ -17,6 +17,10 @@ type AppHandler struct {
ClaimFn func(ctx context.Context, tk *Task) error ClaimFn func(ctx context.Context, tk *Task) error
// RevokeFn cancels the forward on this node (stop worker + drop from store). // RevokeFn cancels the forward on this node (stop worker + drop from store).
RevokeFn func(ctx context.Context, tk *Task) error RevokeFn func(ctx context.Context, tk *Task) error
// RestartFn re-enables an already-claimed forward on this node (clear the
// stopped flag + bring the worker back). Required because a stop keeps the
// topology entry, so the normal claim path will not re-create it.
RestartFn func(ctx context.Context, tk *Task) error
} }
// RuntimeLoad implements Handler. // RuntimeLoad implements Handler.
@ -44,6 +48,14 @@ func (a *AppHandler) Revoke(ctx context.Context, tk *Task) error {
return a.RevokeFn(ctx, tk) return a.RevokeFn(ctx, tk)
} }
// Restart implements Handler.
func (a *AppHandler) Restart(ctx context.Context, tk *Task) error {
if a.RestartFn == nil {
return nil
}
return a.RestartFn(ctx, tk)
}
// SampleMemLoad returns a cheap memory-usage percentage (0..100). // SampleMemLoad returns a cheap memory-usage percentage (0..100).
func SampleMemLoad() float64 { func SampleMemLoad() float64 {
var m runtime.MemStats var m runtime.MemStats

View File

@ -128,7 +128,7 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
if e.send == nil { if e.send == nil {
return nil return nil
} }
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next) debugToken("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
err := e.send(ctx, next, tk) err := e.send(ctx, next, tk)
if err == nil { if err == nil {
if e.state.LeaderID == e.ID { if e.state.LeaderID == e.ID {
@ -155,7 +155,9 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
} }
// Non-leader neighbor death: continue to the next recipient. // Non-leader neighbor death: continue to the next recipient.
} }
e.inflight.clear() // 全环遍历完毕,所有后继都不可达(或已全部尝试过)。
// 保持 inflight(不清空),让 WatchTokenLoss 超时后重发。
// 重发时 AliveSuccessor 重新计算,可能已被心跳复活。
return nil return nil
} }

View File

@ -20,6 +20,8 @@ import (
// LogKind enumerates operation-log entry types. // LogKind enumerates operation-log entry types.
const ( const (
LogForwardAdd = "forward.add" LogForwardAdd = "forward.add"
LogForwardStop = "forward.stop"
LogForwardStart = "forward.start"
LogForwardRemove = "forward.remove" LogForwardRemove = "forward.remove"
LogNodeJoin = "node.join" LogNodeJoin = "node.join"
LogNodeLeave = "node.leave" LogNodeLeave = "node.leave"
@ -140,7 +142,7 @@ func DetailOf(e LogEntry) string {
} }
str := func(key string) string { s, _ := d[key].(string); return s } str := func(key string) string { s, _ := d[key].(string); return s }
switch e.Kind { switch e.Kind {
case LogForwardAdd, LogForwardRemove: case LogForwardAdd, LogForwardStop, LogForwardStart, LogForwardRemove:
s := str("local") + " → " + str("remote") s := str("local") + " → " + str("remote")
if id := str("taskId"); id != "" { if id := str("taskId"); id != "" {
if len(id) > 8 { if len(id) > 8 {

View File

@ -2,14 +2,22 @@ package cluster
import ( import (
"context" "context"
"encoding/json"
"testing" "testing"
"webui4frpc/internal/store" "webui4frpc/internal/store"
) )
// TestRevokeTaskRemovesTopology: a revoke task published to the ring removes // TestRevokeTaskMarksTopologyDisabled: a stop delivered through the ring marks
// the forward from topology, calls Handler.Revoke, and logs forward.remove. // the topology entry disabled instead of deleting it, calls Handler.Revoke, and
func TestRevokeTaskRemovesTopology(t *testing.T) { // logs forward.stop.
//
// The entry is deliberately KEPT: TopoEntry.Link is the carrier that makes the
// stop travel, and State rides the token every cycle. Keeping it is what lets a
// stop reach a member that was offline when the stop was issued — deleting the
// entry left the flag nowhere to live, so a stop converged only if the owning
// node happened to be online for a one-shot revoke task.
func TestRevokeTaskMarksTopologyDisabled(t *testing.T) {
revoked := false revoked := false
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil, eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5}, &fakeHandler{load: Load{MemPct: 5, NetPct: 5},
@ -26,33 +34,92 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
if len(eng.state.TopologyList()) != 1 { if len(eng.state.TopologyList()) != 1 {
t.Fatalf("topology after claim = %+v", eng.state.TopologyList()) t.Fatalf("topology after claim = %+v", eng.state.TopologyList())
} }
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); d {
t.Fatal("a freshly claimed forward must not start out disabled")
}
// publish a revoke task pointing at the same forward // publish a revoke task pointing at the same forward
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081}) eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil { if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(eng.state.TopologyList()) != 0 {
t.Fatalf("topology after revoke = %+v", eng.state.TopologyList()) // The entry must SURVIVE, marked disabled, so the flag keeps travelling.
if len(eng.state.TopologyList()) != 1 {
t.Fatalf("topology entry was removed; the disabled flag would have no carrier: %+v",
eng.state.TopologyList())
}
disabled, known := eng.state.TopologyDisabled("web", "frps1", 18081)
if !known {
t.Fatal("topology entry missing after revoke")
}
if !disabled {
t.Fatal("revoke did not mark the topology entry disabled")
}
// Active must follow the flag, since OfflineReassign only re-queues Active
// entries — a stopped forward must not be resurrected by a node departure.
if eng.state.TopologyList()[0].Active {
t.Fatal("a stopped forward must not remain Active (OfflineReassign would re-queue it)")
} }
if !revoked { if !revoked {
t.Fatal("Handler.Revoke was not called") t.Fatal("Handler.Revoke was not called")
} }
// log should contain forward.remove // log should contain forward.stop (not forward.remove — nothing was removed)
var sawRemove bool var sawStop bool
for _, e := range eng.Log.Snapshot() { for _, e := range eng.Log.Snapshot() {
if e.Kind == LogForwardRemove { if e.Kind == LogForwardStop {
sawRemove = true sawStop = true
} }
} }
if !sawRemove { if !sawStop {
t.Fatalf("log missing forward.remove: %+v", eng.Log.Snapshot()) t.Fatalf("log missing forward.stop: %+v", eng.Log.Snapshot())
}
}
// TestStoppedTopologyEntrySurvivesAdoption is the property that makes the whole
// mark-don't-remove design work: because the entry persists, a node that adopts
// ring state learns the stop. This is the offline-member case a one-shot revoke
// task could never cover.
func TestStoppedTopologyEntrySurvivesAdoption(t *testing.T) {
src := newTestEngine("n1", true)
src.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := src.OnToken(context.Background(), &Token{Cycle: 1, State: src.state}); err != nil {
t.Fatal(err)
}
src.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := src.OnToken(context.Background(), &Token{Cycle: 2, State: src.state}); err != nil {
t.Fatal(err)
}
// A different node adopts the ring state (what e.state = tk.State does).
peer := newTestEngine("n2", false)
peer.AdoptState(src.state)
disabled, known := peer.TopologyDisabled("web", "frps1", 18081)
if !known {
t.Fatal("peer did not receive the topology entry for the stopped forward")
}
if !disabled {
t.Fatal("peer adopted the entry but no longer sees it as disabled — the stop did not propagate")
} }
} }
// TestRevokeIdempotent: revoking an already-missing forward does not error. // TestRevokeIdempotent: revoking an already-missing forward does not error.
//
// It must ALSO still call Handler.Revoke. The handler is what actually stops
// the per-forward frpc worker; the topology entry is only bookkeeping. Gating
// the handler on RemoveTopology()'s return value (as this test used to permit)
// turned every revoke of an already-absent forward into a silent no-op — the
// worker kept running, which is how a forward the user had stopped kept
// dialling a local service that was intentionally down, forever.
func TestRevokeIdempotent(t *testing.T) { func TestRevokeIdempotent(t *testing.T) {
eng := newTestEngine("n1", true) revoked := 0
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
revoke: func(ctx context.Context, tk *Task) error { revoked++; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true, "")
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1}) eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil { if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatalf("revoke missing: %v", err) t.Fatalf("revoke missing: %v", err)
@ -61,4 +128,318 @@ func TestRevokeIdempotent(t *testing.T) {
if len(eng.state.TopologyList()) != 0 { if len(eng.state.TopologyList()) != 0 {
t.Fatal("should be empty") t.Fatal("should be empty")
} }
// The stop side-effect must have happened even though there was no entry.
if revoked != 1 {
t.Fatalf("Handler.Revoke called %d times, want 1 — a revoke with no topology entry "+
"must still stop the worker, otherwise stopped forwards keep running", revoked)
}
}
// TestStoppedForwardNotRequeuedOnNodeDeparture pins why marking (rather than
// deleting) is safe: OfflineReassign only re-queues ACTIVE entries, so a
// stopped forward is not silently handed to another node when its owner goes
// away. Deleting the entry, or leaving it Active, would both resurrect it.
func TestStoppedForwardNotRequeuedOnNodeDeparture(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err)
}
owner := eng.state.TopologyList()[0].OwnerID
eng.state.OfflineReassign(owner)
if n := len(eng.state.PendingList()); n != 0 {
t.Fatalf("a stopped forward was re-queued on the owner's departure (pending=%d): %+v",
n, eng.state.PendingList())
}
}
// TestSubmitTaskNotBlockedByStoppedEntry is the regression test for the bug the
// mark-don't-remove change introduced: SubmitTask's idempotency guard used
// HasTask, which matches a disabled topology entry. Since a stop now KEEPS the
// entry, any submission for that forward while it is still stopped is swallowed
// by its own guard — so a stopped forward can never be started again.
//
// The probe deliberately submits WITHOUT re-enabling first. Re-enabling would
// clear the flag and make HasTask and HasActiveTask agree, hiding the defect;
// the guard has to be exercised at the moment the entry is still stopped.
func TestSubmitTaskNotBlockedByStoppedEntry(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
// stop it -> entry is kept, marked disabled
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err)
}
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); !d {
t.Fatal("precondition: forward should be disabled")
}
// The stopped entry IS findable by the broad predicate — which is exactly why
// the narrow one has to exist.
if !eng.HasTask("web", "frps1", 18081) {
t.Fatal("precondition: the stopped entry should still be findable by HasTask")
}
if eng.HasActiveTask("web", "frps1", 18081) {
t.Fatal("precondition: a stopped forward must NOT count as active")
}
// The regression: a submission must be published for a stopped forward.
// With a HasTask guard this returns nil and the forward is stuck forever.
tk := eng.SubmitTask(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081})
if tk == nil {
t.Fatal("SubmitTask was swallowed by the stopped topology entry — " +
"a stopped forward could never be started again")
}
if tk.Link.RemotePort != 18081 {
t.Fatalf("unexpected task: %+v", tk)
}
}
// TestSubmitTaskStillDedupesActiveForward is the other half of the guard: the
// narrower predicate must not turn SubmitTask into a duplicate-task generator.
func TestSubmitTaskStillDedupesActiveForward(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
if !eng.HasActiveTask("web", "frps1", 18081) {
t.Fatal("precondition: a claimed forward must count as active")
}
if tk := eng.SubmitTask(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081}); tk != nil {
t.Fatalf("SubmitTask must stay idempotent for an active forward, got %+v", tk)
}
}
// TestStoppedForwardNotRequeuedOnNodeDeparture pins why marking (rather than
// deleting) is safe: OfflineReassign only re-queues ACTIVE entries, so a
// stopped forward is not silently handed to another node when its owner goes
// TestAddTopologyRespectsDisabledFlag: a claim that carries a stopped forward
// must not mark the new entry Active, or it would come back to life.
func TestAddTopologyRespectsDisabledFlag(t *testing.T) {
s := &State{Topology: map[string]*TopoEntry{}}
e := s.AddTopology(&Task{
ID: "t1", Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
Link: store.Link{RemotePort: 18081, Disabled: true},
}, "n1")
if e.Active {
t.Fatal("AddTopology forced Active=true for a disabled claim — the forward would resurrect")
}
if !e.Link.Disabled {
t.Fatal("AddTopology dropped the disabled flag from the link")
}
}
// TestRestartTaskBypassesDuplicateClaimGuard is the engine half of the
// re-enable path: a restart task for a forward that still HAS a topology entry
// must reach Handler.Restart.
//
// This is precisely what the duplicate-claim guard refuses — it exists to stop
// a stale task from spawning a second worker for an owned forward, and a restart
// is indistinguishable from that unless it is an explicit task kind. Marking
// the entry stopped (instead of deleting it) is what made this necessary: the
// entry the guard keys on now outlives a stop.
func TestRestartTaskBypassesDuplicateClaimGuard(t *testing.T) {
restarts := 0
claims := 0
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
claim: func(ctx context.Context, tk *Task) error { claims++; return nil },
restart: func(ctx context.Context, tk *Task) error { restarts++; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true, "")
// Establish + stop: entry survives, marked disabled.
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err)
}
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); !d {
t.Fatal("precondition: should be disabled")
}
entryCount := len(eng.state.TopologyList())
// Start: publish a restart and run a cycle.
eng.state.UpdateTopologyDisabled("web", "frps1", 18081, false)
eng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081})
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 3, State: eng.state}); err != nil {
t.Fatal(err)
}
if restarts != 1 {
t.Fatalf("Handler.Restart called %d times, want 1 — the restart task was swallowed "+
"by the duplicate-claim guard, so the forward would never come back", restarts)
}
if claims != 1 {
t.Fatalf("Handler.Claim called %d times, want 1 (the original claim only); "+
"a restart must not re-run the claim bookkeeping", claims)
}
// Re-enabling must not create a second entry.
if n := len(eng.state.TopologyList()); n != entryCount {
t.Fatalf("restart created a duplicate topology entry: %d -> %d", entryCount, n)
}
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); d {
t.Fatal("entry is still disabled after the restart task was applied")
}
var sawStart bool
for _, e := range eng.Log.Snapshot() {
if e.Kind == LogForwardStart {
sawStart = true
}
}
if !sawStart {
t.Fatalf("log missing forward.start: %+v", eng.Log.Snapshot())
}
}
// TestRestartFlagSurvivesTokenSerialization: the restart task travels to the
// owner inside the token, so the flag must round-trip through JSON. Without the
// struct tag it would silently deserialize as false and the owner would treat
// it as an ordinary claim — which the duplicate guard then discards.
func TestRestartFlagSurvivesTokenSerialization(t *testing.T) {
eng := newTestEngine("n1", true)
eng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081})
blob, err := json.Marshal(&Token{Cycle: 7, State: eng.state})
if err != nil {
t.Fatal(err)
}
var back Token
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatal(err)
}
var found *Task
for _, tk := range back.State.PendingList() {
if tk.Local.Name == "web" && tk.Link.RemotePort == 18081 {
found = tk
}
}
if found == nil {
t.Fatal("restart task did not survive token serialization")
}
if !found.Restart {
t.Fatal("the restart flag was lost in JSON — the owner would see a plain claim " +
"and the duplicate guard would discard it")
}
// Revoke and Restart must stay distinguishable.
if found.Revoke {
t.Fatal("a restart task must not also read as a revocation")
}
}
// TestRestartTaskReachesNonLocalOwner.
func TestRestartOnlyAppliedByOwner(t *testing.T) {
restarts := 0
h := &fakeHandler{load: Load{MemPct: 5, NetPct: 5},
restart: func(ctx context.Context, tk *Task) error { restarts++; return nil }}
// Owner node claims the forward.
owner := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil, h,
func(ctx context.Context, next string, tk *Token) error { return nil }, "n1:7500", true, "")
owner.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := owner.OnToken(context.Background(), &Token{Cycle: 1, State: owner.state, SentAt: 1}); err != nil {
t.Fatal(err)
}
if len(owner.state.TopologyList()) != 1 {
t.Fatalf("precondition: owner should hold the entry, got %+v", owner.state.TopologyList())
}
// A peer adopts the same ring state but does not own the forward.
peer := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil, h,
func(ctx context.Context, next string, tk *Token) error { return nil }, "n2:7500", false, "")
peer.AdoptState(owner.state)
tk := peer.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081})
if tk == nil {
t.Fatal("SubmitRestart returned nil")
}
// The peer processes one token: it must NOT apply the restart.
if _, err := peer.OnToken(context.Background(), &Token{Cycle: 2, State: peer.state, SentAt: 2}); err != nil {
t.Fatal(err)
}
if restarts != 0 {
t.Fatalf("a NON-owner applied the restart %d time(s) — it would spawn an orphaned "+
"worker for a forward owned by somebody else", restarts)
}
}
// TestRestartTaskReachesNonLocalOwner: a restart published on a node that is NOT
// the owner must survive the token round-trip and be applied by the owner.
//
// The naive "if not owner then continue" implementation CONSUMED the task
// (ClaimPending had already removed it), so the owner never received it and the
// forward stayed stopped with nothing logged as an error. Deferral must
// re-queue it.
//
// Each OnToken is fed a FRESH token (as the real ring does — the successor's
// OnToken receives the state the predecessor returned), so the deferral is
// exercised once per hop rather than re-processing one snapshot.
func TestRestartTaskReachesNonLocalOwner(t *testing.T) {
ownerApplied, submitterApplied := 0, 0
ownerEng := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
restart: func(ctx context.Context, tk *Task) error { ownerApplied++; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil }, "n2:7500", false, "")
ownerEng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if _, err := ownerEng.OnToken(context.Background(), &Token{Cycle: 1, State: ownerEng.state}); err != nil {
t.Fatal(err)
}
if len(ownerEng.state.TopologyList()) != 1 {
t.Fatalf("precondition: n2 should own it, got %+v", ownerEng.state.TopologyList())
}
subEng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
restart: func(ctx context.Context, tk *Task) error { submitterApplied++; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil }, "n1:7500", true, "")
subEng.AdoptState(ownerEng.state)
subEng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
store.Link{RemotePort: 18081})
// One hop through the non-owner: it must defer, not apply and not drop.
out, err := subEng.OnToken(context.Background(), &Token{Cycle: 2, State: subEng.state, SentAt: 1})
if err != nil {
t.Fatal(err)
}
if submitterApplied != 0 {
t.Fatal("the non-owner applied a restart for a forward it does not own")
}
var carried *Task
for _, tk := range out.State.PendingList() {
if tk.Restart && tk.Local.Name == "web" {
carried = tk
}
}
if carried == nil {
t.Fatal("the non-owner CONSUMED the restart task; the owner would never receive it")
}
// The owner applies the carried task.
if _, err := ownerEng.OnToken(context.Background(), &Token{Cycle: 3, State: out.State, SentAt: 2}); err != nil {
t.Fatal(err)
}
if ownerApplied != 1 {
t.Fatalf("the owner applied the restart %d times, want 1", ownerApplied)
}
} }

View File

@ -8,7 +8,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"time" "time"
) )
@ -52,11 +51,16 @@ func (t *TokenTransport) SendTo(getAddr func(nodeID string) string) func(ctx con
start := time.Now() start := time.Now()
resp, err := cli.Do(req) resp, err := cli.Do(req)
if err != nil { if err != nil {
log.Printf("token-send %s: size=%dB elapsed=%v err=%v", addr, len(body), time.Since(start).Round(time.Millisecond), err) // NOT demoted: a failed send is the "neighbor offline" signal the
// ring's fault paths are diagnosed from.
logf("token-send %s: size=%dB elapsed=%v err=%v", addr, len(body), time.Since(start).Round(time.Millisecond), err)
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
log.Printf("token-send %s: size=%dB elapsed=%v -> %d", addr, len(body), time.Since(start).Round(time.Millisecond), resp.StatusCode) // Success path is a per-round heartbeat (size/elapsed/200 repeat
// verbatim every cycle) — debug only. The status-code check below stays
// loud on purpose, so a non-2xx still shows up without the debug flag.
debugToken("token-send %s: size=%dB elapsed=%v -> %d", addr, len(body), time.Since(start).Round(time.Millisecond), resp.StatusCode)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode) return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode)
} }

View File

@ -0,0 +1,204 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"webui4frpc/internal/cluster"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
)
// newRingTestHandler builds a Handler WITH a ring engine attached, so the
// paths that publish tasks into the token actually execute. newTestHandler
// leaves Ring nil, which silently skips them — a test built on it can pass
// while the publish side is completely broken.
func newRingTestHandler(t *testing.T) (*Handler, *cluster.Engine, *httptest.Server) {
t.Helper()
dir := t.TempDir()
st, err := store.New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
pm := process.NewManager(process.Options{
ConfigsDir: filepath.Join(dir, "configs"),
LogsDir: filepath.Join(dir, "logs"),
BinaryPath: func() string { return "" },
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
AutoRestart: func(string) bool { return false },
RestartInterval: func() int { return 5 },
})
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
&cluster.AppHandler{},
func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
"n1:7500", true, "")
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
mux, err := NewServeMux(h)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return h, ring, ts
}
func saveCanvas(t *testing.T, srv *httptest.Server, body string) {
t.Helper()
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/manager/canvas", bytes.NewBufferString(body))
req.SetBasicAuth("admin", "pw")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("canvas save status = %d", resp.StatusCode)
}
}
// TestStopForwardPublishesDisabledFlagInRevokeTask is the regression test for
// the actual defect.
//
// stopForward() read the link, called SetLinkDisabled(true), and then handed
// the STALE copy (disabled=false) to RevokeTask. The revoke travels to the
// node that OWNS the forward, and that node's Claim/Revoke path keys off the
// flag — so a stale false meant:
// - the owner could not tell the stop was deliberate, and
// - nothing retired the topology entry,
//
// so the next restart's reconcile re-claimed the forward and spawned a worker
// for something the user had stopped (seen live: endless connection-refused
// against an intentionally-down service).
//
// This asserts the flag ON THE PUBLISHED TASK, which is the value that was
// actually wrong. It cannot be satisfied by the store write alone.
func TestStopForwardPublishesDisabledFlagInRevokeTask(t *testing.T) {
_, ring, ts := newRingTestHandler(t)
saveCanvas(t, ts, `{
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
}`)
// Stop the forward over the API.
b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/stop", bytes.NewReader(b))
req.SetBasicAuth("admin", "pw")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("stop status = %d", resp.StatusCode)
}
// Find the revoke task that was published into the token.
var revoke *cluster.Task
for _, tk := range ring.State().PendingList() {
if tk.Revoke && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 {
revoke = tk
break
}
}
if revoke == nil {
t.Fatal("stop did not publish a revoke task for the forward")
}
if !revoke.Link.Disabled {
t.Fatal("the published revoke task carries disabled=false — the owner node cannot tell " +
"this stop was deliberate, which is the bug that let stopped forwards resurrect")
}
}
// TestStopThenStartPublishesRestartTask guards the failure mode that
// mark-don't-remove introduces, and that only shows up on the WIRE.
//
// A stop keeps the topology entry (it is the carrier for the flag), so on
// re-enable:
// - SubmitTask dedupes, because the entry exists;
// - the claim path discards any task for a forward that already has an owner.
//
// Both channels therefore refuse, no task is published, the owner never learns
// about the re-enable, and the forward stays stopped forever — a forward the
// user can stop but never restart.
//
// Asserting the task is PUBLISHED is the point: asserting only that the flag
// cleared would pass while nothing reached the owner. (The earlier version of
// this test did exactly that and stayed green against the broken code.)
func TestStopThenStartPublishesRestartTask(t *testing.T) {
_, ring, ts := newRingTestHandler(t)
saveCanvas(t, ts, `{
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
}`)
// Claim it so a topology entry exists (that is what makes the two normal
// channels refuse later).
if _, err := ring.OnToken(context.Background(), &cluster.Token{Cycle: 1, State: *ring.State()}); err != nil {
t.Fatal(err)
}
if !ring.HasActiveTask("svc", "srv-a", 45999) {
t.Fatalf("precondition: forward should be active, topo=%+v", ring.State().TopologyList())
}
forwards := func(action string) {
t.Helper()
b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/"+action, bytes.NewReader(b))
req.SetBasicAuth("admin", "pw")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status = %d", action, resp.StatusCode)
}
}
// --- stop: entry kept, marked disabled -------------------------------
forwards("stop")
if d, known := ring.TopologyDisabled("svc", "srv-a", 45999); !known || !d {
t.Fatalf("stop did not mark the topology entry disabled (known=%v disabled=%v)", known, d)
}
if ring.HasActiveTask("svc", "srv-a", 45999) {
t.Fatal("a stopped forward must not count as active")
}
// --- start: a task MUST reach the owner ------------------------------
forwards("start")
if d, _ := ring.TopologyDisabled("svc", "srv-a", 45999); d {
t.Fatal("start did not clear the disabled flag")
}
if !ring.HasActiveTask("svc", "srv-a", 45999) {
t.Fatal("after start the forward must be active again")
}
// The actual regression: something has to be queued for the owner. The
// re-enable must be published as a restart task, since a plain creation task
// would be deduped or discarded.
var restart *cluster.Task
for _, tk := range ring.State().PendingList() {
if tk.Restart && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 {
restart = tk
break
}
}
if restart == nil {
t.Fatalf("start published no restart task — the owner would never bring the "+
"worker back, leaving the forward permanently stopped (pending=%+v)",
ring.State().PendingList())
}
if restart.Link.Disabled {
t.Fatal("the restart task carries disabled=true, so the owner would re-apply the stop")
}
}

View File

@ -0,0 +1,141 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// stopForwardReq mirrors the forwards start/stop request body.
type stopForwardReq struct {
Local string `json:"local"`
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
}
func postForwards(t *testing.T, srv *httptest.Server, action string, body stopForwardReq) int {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/manager/forwards/"+action, bytes.NewReader(b))
req.SetBasicAuth("admin", "pw")
req.Header.Set("Content-Type", "application/json")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
// TestStopForwardPersistsDisabledFlag is the regression test for the
// "stopped forwards resurrect on restart" bug.
//
// The original defect: stopForward() read the link, called
// SetLinkDisabled(true), but then handed the STALE (disabled=false) copy to
// RevokeTask — so the disabled flag never reached the node owning the forward,
// and nothing removed the topology entry. On the next restart the startup
// reconcile saw an owned forward with no worker and re-claimed it, spawning a
// worker for a forward the user had deliberately stopped (observed live:
// ~14k "proxy already exists" retries and endless connection-refused against a
// service that was intentionally down).
//
// The contract this pins: after a successful stop, the persisted link MUST be
// disabled — that flag is the single source of truth the claim path consults.
func TestStopForwardPersistsDisabledFlag(t *testing.T) {
h, ts := newTestHandler(t)
body := `{
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
}`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
req.SetBasicAuth("admin", "pw")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("canvas save status = %d", resp.StatusCode)
}
// The forward starts enabled.
ln, found, err := h.Store.LinkByTriple("svc", "srv-a", 45999)
if err != nil || !found {
t.Fatalf("link not persisted: found=%v err=%v", found, err)
}
if ln.Disabled {
t.Fatal("a freshly saved forward must start enabled")
}
// Stop it.
if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
t.Fatalf("stop status = %d, want 200", code)
}
// Persisted flag must now be set — this is what the claim path reads.
ln, found, err = h.Store.LinkByTriple("svc", "srv-a", 45999)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("link vanished after stop; stop must be non-destructive")
}
if !ln.Disabled {
t.Fatal("stop did not persist disabled=true — the startup reconcile would resurrect this forward")
}
// Start must clear it again (the user-facing re-enable path).
if code := postForwards(t, ts, "start", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
t.Fatalf("start status = %d, want 200", code)
}
ln, _, err = h.Store.LinkByTriple("svc", "srv-a", 45999)
if err != nil {
t.Fatal(err)
}
if ln.Disabled {
t.Fatal("start did not clear disabled; a re-enabled forward would stay stopped")
}
}
// TestStopForwardIsNonDestructive pins the per-forward stop semantics the
// revoke path was specifically rewritten for: stopping one forward must not
// touch a sibling forward that shares the same local or remote.
func TestStopForwardIsNonDestructive(t *testing.T) {
h, ts := newTestHandler(t)
body := `{
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
"links": [
{"local":"svc","remote":"srv-a","remotePort":45999},
{"local":"svc","remote":"srv-a","remotePort":46000}
]
}`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
req.SetBasicAuth("admin", "pw")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
t.Fatalf("stop status = %d", code)
}
stopped, _, _ := h.Store.LinkByTriple("svc", "srv-a", 45999)
sibling, found, _ := h.Store.LinkByTriple("svc", "srv-a", 46000)
if !found {
t.Fatal("sibling forward was destroyed by stopping its neighbour")
}
if !stopped.Disabled {
t.Error("the stopped forward should be disabled")
}
if sibling.Disabled {
t.Error("the sibling forward must stay enabled — per-forward stop, not per-local/remote")
}
}

View File

@ -245,7 +245,12 @@ func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *ca
} }
if ln.Disabled { if ln.Disabled {
// Stopped on the forwards page: make sure it leaves the topology. // Stopped on the forwards page: make sure it leaves the topology.
if h.Ring.HasTask(ln.Local, ln.Remote, ln.RemotePort) { // Guard on an ACTIVE task — an entry that is already marked
// disabled has nothing left to revoke, and re-issuing a revocation
// for it on every canvas save would be pure noise. (HasTask, which
// also matches disabled entries, is the right predicate for the
// "already handled" question this branch is not asking.)
if h.Ring.HasActiveTask(ln.Local, ln.Remote, ln.RemotePort) {
h.Ring.RevokeTask(loc, rem, ln) h.Ring.RevokeTask(loc, rem, ln)
} }
continue continue

View File

@ -84,6 +84,25 @@ func (h *Handler) startForward(local, remote string, port int) error {
} }
return nil return nil
} }
if h.Ring != nil {
// Publish the enable into the topology so it rides the ring (a peer that
// still holds the stale "stopped" copy learns about it on adoption).
h.Ring.UpdateTopologyDisabled(local, remote, port, false)
}
// A cluster forward whose topology entry still exists needs its OWNER to
// bring the worker back, and neither of the normal channels can do it:
// - SubmitTask is idempotency-guarded, and the entry still exists (a stop
// keeps it as the flag's carrier), so the submission is deduped away;
// - the claim path drops any task for a forward that already has an
// owner, as a defence against duplicate-claim collisions.
// So a re-enable of an existing entry is published as a dedicated RESTART
// task, which the owner applies unconditionally. Without it a forward the
// user can stop but not restart — which is what marking-instead-of-removing
// would otherwise have produced.
if h.Ring != nil && h.Ring.HasTopologyEntry(local, remote, port) {
h.Ring.SubmitRestart(loc, rem, ln)
return nil
}
if h.Ring != nil { if h.Ring != nil {
h.Ring.SubmitTask(loc, rem, ln) h.Ring.SubmitTask(loc, rem, ln)
} }
@ -109,6 +128,24 @@ func (h *Handler) stopForward(local, remote string, port int) error {
} else { } else {
_ = h.Store.SetLinkDisabled(local, remote, port, true) _ = h.Store.SetLinkDisabled(local, remote, port, true)
} }
// The revoke task travels to whichever node OWNS the forward, and that node
// re-reads the disabled flag from its own store before starting a worker —
// so the flag has to be set on every node that has a copy of this link, not
// just the one handling this request. Propagating Disabled on the task lets
// the owner's RevokeFn stop the worker even if its own store row is stale.
//
// This also fixes a latent inconsistency: `ln` was read BEFORE the
// SetLinkDisabled(true) above, so the link published into the token still
// carried disabled=false and got copied into the topology entry verbatim.
ln.Disabled = true
// Publish the stop into the topology BEFORE revoking: the revoke retires the
// own-side worker/entry, so the flag must already exist somewhere that
// survives it and travels the ring. This is the send half of cluster-wide
// disabled propagation (see store.ReconcileLinkDisabled for the receive
// half).
if h.Ring != nil {
h.Ring.UpdateTopologyDisabled(local, remote, port, true)
}
if loc.LocalOnly { if loc.LocalOnly {
if h.Process != nil { if h.Process != nil {
key := process.WorkerKey(local, remote, port) key := process.WorkerKey(local, remote, port)

View File

@ -9,6 +9,8 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"log"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -18,6 +20,7 @@ import (
"webui4frpc/internal/cluster" "webui4frpc/internal/cluster"
"webui4frpc/internal/process" "webui4frpc/internal/process"
"webui4frpc/internal/store" "webui4frpc/internal/store"
"webui4frpc/internal/version"
) )
//go:embed all:dist //go:embed all:dist
@ -411,7 +414,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
} }
resp := map[string]any{ resp := map[string]any{
"version": "0.1.0", "version": version.Version,
"workDir": h.WorkDir, "workDir": h.WorkDir,
"settings": settings, "settings": settings,
"services": locals, "services": locals,
@ -538,11 +541,31 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader // predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader // → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
// 退出节点时,让leader上邻居意识到当前环已经没有节点了". // 退出节点时,让leader上邻居意识到当前环已经没有节点了".
//
// 同时,心跳到达说明前驱还活着,leader 应在地自己的拓扑中把它标记为
// alive。否则 forwardToNext 一次超时把前驱 MarkOffline 后就再没有
// 途径恢复它——前驱明明活着,leader 却认为它 offline → AliveSuccessor
// 找不到后继,令牌冻结在 leader 手上。心跳是 leader 侧纠正误判的唯一
// 入口(因为心跳只有前驱发给 leader 这一条边)。
if r.Body == nil { if r.Body == nil {
if s := h.Ring.State(); len(s.Nodes) <= 1 { if s := h.Ring.State(); len(s.Nodes) <= 1 {
http.Error(w, "standalone node", http.StatusConflict) http.Error(w, "standalone node", http.StatusConflict)
return return
} }
// 心跳到达 = 前驱活着,标记 alive(纠正 forwardToNext 的误判)
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
@ -552,6 +575,20 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "standalone node", http.StatusConflict) http.Error(w, "standalone node", http.StatusConflict)
return return
} }
// 同上:心跳到达 = 前驱活着
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }

288
internal/store/link_test.go Normal file
View File

@ -0,0 +1,288 @@
package store
import (
"path/filepath"
"testing"
)
// seed inserts the local + remote rows a link's foreign keys require.
// (links.local / links.remote reference their own tables, so a link cannot
// exist on its own — the same reason ClaimFn upserts them before ReplaceLinks.)
func seed(t *testing.T, st *Store, locals []string, remote string) {
t.Helper()
for _, n := range locals {
if err := st.UpsertLocal(Local{Name: n, IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
t.Fatal(err)
}
}
if err := st.UpsertRemote(Remote{Name: remote, IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
t.Fatal(err)
}
}
// TestLinkByTripleSurvivesReplaceLinks pins the reason LinkByTriple exists.
//
// ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so sqlite
// hands every row a FRESH autoincrement id. A Link captured before such a
// write (e.g. one riding inside a ring token) therefore carries an id that
// either matches a different forward or matches nothing. The natural key
// (local, remote, remotePort) is what every caller actually identifies a
// forward by, and it must survive those rewrites.
func TestLinkByTripleSurvivesReplaceLinks(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
links := []Link{
{Local: "alpha", Remote: "srv", RemotePort: 100},
{Local: "beta", Remote: "srv", RemotePort: 200},
{Local: "gamma", Remote: "srv", RemotePort: 300},
}
if err := st.ReplaceLinks(links); err != nil {
t.Fatal(err)
}
// Capture the ids as the ring would have them.
before := map[string]int64{}
all, err := st.ListLinks()
if err != nil {
t.Fatal(err)
}
for _, l := range all {
before[l.Local] = l.ID
}
if len(before) != 3 {
t.Fatalf("expected 3 links, got %d", len(before))
}
// Rewrite the table (this is what saveCanvas and ClaimFn both do).
if err := st.ReplaceLinks(links); err != nil {
t.Fatal(err)
}
after, err := st.ListLinks()
if err != nil {
t.Fatal(err)
}
if len(after) != 3 {
t.Fatalf("expected 3 links after rewrite, got %d", len(after))
}
// The natural key must still resolve to the right forward, with its
// disabled flag and group intact.
for _, l := range after {
if l.Disabled {
t.Errorf("link %s unexpectedly disabled after a plain rewrite", l.Local)
}
}
got, found, err := st.LinkByTriple("beta", "srv", 200)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("LinkByTriple failed to find beta after ReplaceLinks")
}
if got.Local != "beta" || got.RemotePort != 200 {
t.Fatalf("LinkByTriple returned the wrong row: %+v", got)
}
}
// TestLinkByTripleNotFoundIsNotError documents the contract callers rely on:
// "no persisted opinion yet" is (Link{}, false, nil), not an error. A fresh
// claim of a link with no row must be allowed to start.
func TestLinkByTripleNotFoundIsNotError(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
got, found, err := st.LinkByTriple("nope", "srv", 1234)
if err != nil {
t.Fatalf("missing link must not be an error, got %v", err)
}
if found {
t.Fatalf("missing link reported as found: %+v", got)
}
if got.Local != "" || got.RemotePort != 0 {
t.Fatalf("expected zero Link on miss, got %+v", got)
}
}
// TestLinkByTripleReadsDisabledFlag is the store-level half of the
// "stopped forwards resurrect on restart" bug: the claim path asks the store
// whether the user disabled this forward, so this lookup must return the flag
// as persisted.
func TestLinkByTripleReadsDisabledFlag(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"mc"}, "srv")
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
t.Fatal(err)
}
if err := st.SetLinkDisabled("mc", "srv", 25565, true); err != nil {
t.Fatal(err)
}
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("expected to find the link")
}
if !ln.Disabled {
t.Fatal("expected Disabled=true to be visible through LinkByTriple")
}
if ln.Group != "game" {
t.Fatalf("group should survive, got %q", ln.Group)
}
// ...and the user-facing start path must be able to clear it again.
if err := st.SetLinkDisabled("mc", "srv", 25565, false); err != nil {
t.Fatal(err)
}
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
t.Fatal("expected Disabled=false after clearing")
}
}
// TestGetLinkByIDIsStaleAfterReplaceLinks documents WHY callers must not use
// GetLink(id) with a previously captured id. It is not a fix — it is the trap
// being pinned shut, so the hazard stays visible if someone reintroduces it.
func TestGetLinkByIDIsStaleAfterReplaceLinks(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
if err := st.ReplaceLinks([]Link{
{Local: "alpha", Remote: "srv", RemotePort: 100},
{Local: "beta", Remote: "srv", RemotePort: 200},
}); err != nil {
t.Fatal(err)
}
all, _ := st.ListLinks()
var staleID int64
for _, l := range all {
if l.Local == "alpha" {
staleID = l.ID
}
}
if err := st.ReplaceLinks([]Link{
{Local: "alpha", Remote: "srv", RemotePort: 100},
{Local: "beta", Remote: "srv", RemotePort: 200},
{Local: "gamma", Remote: "srv", RemotePort: 300},
}); err != nil {
t.Fatal(err)
}
// The old id may still resolve, but to whatever row now occupies that
// id — which is exactly the silent-mis-target hazard. Assert that the
// natural key remains the only safe handle.
if ln, ok := st.GetLink(staleID); ok && ln.Local != "alpha" {
t.Logf("stale id %d now points at %q (hazard confirmed; use LinkByTriple)", staleID, ln.Local)
}
if got, found, _ := st.LinkByTriple("alpha", "srv", 100); !found || got.Local != "alpha" {
t.Fatalf("natural key must stay reliable, got %+v found=%v", got, found)
}
}
// TestReconcileLinkDisabledLearnsPeerDecision is the store half of
// cluster-wide stop propagation. A node that did NOT serve the stop request has
// no reason to know about it, and its links table is node-local — so the flag
// arrives via the ring and lands here. The case that matters is the OWNER of a
// forward on a different machine: before this existed, that node's copy still
// read "enabled", so it kept (or re-spawned) the worker for a forward the user
// had explicitly stopped.
func TestReconcileLinkDisabledLearnsPeerDecision(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"mc"}, "srv")
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
t.Fatal(err)
}
// A peer's stop arrives.
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, true); err != nil {
t.Fatal(err)
}
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("link disappeared during reconcile")
}
if !ln.Disabled {
t.Fatal("the peer's stop did not land in the local store")
}
if ln.Group != "game" {
t.Fatalf("reconcile must not clobber other fields, group=%q", ln.Group)
}
// A peer's re-enable arrives.
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, false); err != nil {
t.Fatal(err)
}
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
t.Fatal("the peer's re-enable did not land")
}
}
// TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward: a node that has
// never seen the forward still must remember that it is stopped, otherwise a
// later claim on that node would resurrect it.
func TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"ghost"}, "srv")
if err := st.ReconcileLinkDisabled("ghost", "srv", 9999, true); err != nil {
t.Fatal(err)
}
ln, found, err := st.LinkByTriple("ghost", "srv", 9999)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("a stopped-but-unknown forward must be remembered, or a later claim resurrects it")
}
if !ln.Disabled {
t.Fatal("placeholder is not marked disabled")
}
}
// TestReconcileLinkDisabledIgnoresEnableForUnknown: an enable for a forward this
// node has never seen must NOT create a row. Creating one would invent forwards
// out of ring state.
func TestReconcileLinkDisabledIgnoresEnableForUnknown(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
seed(t, st, []string{"other"}, "srv")
if err := st.ReconcileLinkDisabled("unknown", "srv", 1234, false); err != nil {
t.Fatal(err)
}
if _, found, _ := st.LinkByTriple("unknown", "srv", 1234); found {
t.Fatal("an enable for an unknown forward must not materialise a row")
}
}

View File

@ -560,6 +560,30 @@ func (s *Store) GetLink(id int64) (Link, bool) {
return l, true return l, true
} }
// LinkByTriple looks a link up by its natural key (local, remote, remotePort).
//
// Prefer this over GetLink(id) whenever the caller only knows the forward's
// identity: ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so
// every row gets a fresh autoincrement id. Any id captured before such a write
// (e.g. a Link carried inside a ring token) is stale by definition and will
// either miss or — worse — match a different forward. The natural key is
// stable across those rewrites.
//
// Returns (link, found). A missing row is (Link{}, false) and is NOT an error:
// callers use that to mean "no persisted opinion yet".
func (s *Store) LinkByTriple(local, remote string, port int) (Link, bool, error) {
var l Link
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE local = ? AND remote = ? AND remote_port = ?", local, remote, port).
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Link{}, false, nil
}
return Link{}, false, err
}
return l, true, nil
}
// DeleteLink removes a single link by id. // DeleteLink removes a single link by id.
func (s *Store) DeleteLink(id int64) error { func (s *Store) DeleteLink(id int64) error {
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id) _, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
@ -639,6 +663,11 @@ func (s *Store) ReplaceLinks(links []Link) error {
// SetLinkDisabled flips the disabled flag of a forward identified by its // SetLinkDisabled flips the disabled flag of a forward identified by its
// (local, remote, remotePort) natural key. This is the persistence half of the // (local, remote, remotePort) natural key. This is the persistence half of the
// forwards-page start/stop toggle; the caller also drives the worker/ring side. // forwards-page start/stop toggle; the caller also drives the worker/ring side.
//
// Kept as a targeted UPDATE rather than a ReplaceLinks rewrite on purpose:
// ReplaceLinks deletes and reinserts every row, handing out fresh autoincrement
// ids and invalidating any Link a caller captured earlier (they travel inside
// ring tokens). Flipping one flag must not perturb other rows' identity.
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error { func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?", "UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
@ -647,6 +676,52 @@ func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) e
return err return err
} }
// ReconcileLinkDisabled applies a cluster-wide view of one forward's disabled
// flag into the local store, creating a placeholder row when this node has none
// yet.
//
// This is the receive half of disabled-flag propagation. The stop decision is
// made on whichever node served the request, then rides the token ring in the
// topology entry; every other member calls this on adoption so its own
// links table agrees. Without it the flag lived only on the node that handled
// the request, and the node actually OWNS the forward — usually a different
// machine — still believed the forward was enabled and re-spawned its worker.
//
// A placeholder row is deliberate: a node that has never seen the forward still
// needs to remember "this is stopped" so a later claim on this node cannot
// resurrect it. The placeholder carries the same natural key, so a subsequent
// real claim fills in the rest.
func (s *Store) ReconcileLinkDisabled(local, remote string, port int, disabled bool) error {
cur, found, err := s.LinkByTriple(local, remote, port)
if err != nil {
return err
}
if found {
if cur.Disabled == disabled {
return nil // already agrees; avoid needless writes every token cycle
}
return s.SetLinkDisabled(local, remote, port, disabled)
}
if !disabled {
// Nothing to remember: an unknown forward with no entry is simply
// "not stopped", which is the default the claim path already assumes.
return nil
}
// Need a placeholder, which requires the local/remote foreign keys to exist.
if _, ok := s.GetLocal(local); !ok {
return nil // cannot materialise a link without its local peer row
}
if _, ok := s.GetRemote(remote); !ok {
return nil
}
links, err := s.ListLinks()
if err != nil {
return err
}
links = append(links, Link{Local: local, Remote: remote, RemotePort: port, Disabled: true})
return s.ReplaceLinks(links)
}
// SetLinkGroup assigns a management group label to a forward identified by its // SetLinkGroup assigns a management group label to a forward identified by its
// (local, remote, remotePort) natural key. Empty string clears the group // (local, remote, remotePort) natural key. Empty string clears the group
// (moves the forward to 未分组). This is the persistence half of the // (moves the forward to 未分组). This is the persistence half of the

View File

@ -0,0 +1,12 @@
// Package version holds the single source of truth for the build version.
//
// Value is overridable at build time so release artifacts carry the real tag:
//
// go build -ldflags="-X webui4frpc/internal/version.Version=0.1.2" ./cmd/webui4frpc
//
// Kept in its own package because both cmd/ and internal/httpapi report it
// (main can't be imported, and httpapi must not depend on cmd).
package version
// Version is the running build's semantic version, without a leading "v".
var Version = "0.1.2"

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,8 @@
# webui4frpc runtime configuration
# 默认监听全部接口的 7500 端口;请修改密码后再投入生产。
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
# 可选:首次提供的 frpc 二进制路径(留空则从 Web UI 手动/自动安装)
# W4F_FRPC=/usr/local/bin/frpc

View File

@ -0,0 +1,25 @@
[Unit]
Description=webui4frpc - visual frpc controller (cluster node)
Documentation=https://gitcode.com/JianFeeeee/webui4frpc
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=webui4frpc
Group=webui4frpc
EnvironmentFile=/etc/webui4frpc/webui4frpc.env
ExecStart=/usr/bin/webui4frpc -addr ${W4F_ADDR} -user ${W4F_USER} -password ${W4F_PASSWORD} -workdir /var/lib/webui4frpc
Restart=always
RestartSec=5
LimitNOFILE=65535
# hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/webui4frpc
[Install]
WantedBy=multi-user.target

42
packaging/deb/postinst Executable file
View File

@ -0,0 +1,42 @@
#!/bin/sh
# webui4frpc deb/rpm 安装后脚本:建用户、建目录、装服务
set -e
if ! getent group webui4frpc >/dev/null 2>&1; then
groupadd --system webui4frpc
fi
if ! getent passwd webui4frpc >/dev/null 2>&1; then
useradd --system --gid webui4frpc \
--home-dir /var/lib/webui4frpc --no-create-home \
--shell /usr/sbin/nologin \
--comment "webui4frpc service account" webui4frpc
fi
mkdir -p /var/lib/webui4frpc
chown -R webui4frpc:webui4frpc /var/lib/webui4frpc
chmod 750 /var/lib/webui4frpc
chown root:webui4frpc /etc/webui4frpc/webui4frpc.env 2>/dev/null || true
chmod 640 /etc/webui4frpc/webui4frpc.env 2>/dev/null || true
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable webui4frpc.service || true
if [ -z "${DPKG_ROOT:-}" ]; then
systemctl restart webui4frpc.service || true
fi
fi
cat <<'EOF'
webui4frpc 已安装。
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
数据: /var/lib/webui4frpc
服务: systemctl status webui4frpc
界面: http://<本机地址>:7500
改完配置后执行: systemctl restart webui4frpc
EOF
exit 0

20
packaging/deb/postrm Executable file
View File

@ -0,0 +1,20 @@
#!/bin/sh
# webui4frpc deb 卸载后脚本:purge 时清数据与账号
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
if [ "$1" = "purge" ]; then
rm -rf /var/lib/webui4frpc
rm -rf /etc/webui4frpc
if getent passwd webui4frpc >/dev/null 2>&1; then
userdel webui4frpc || true
fi
if getent group webui4frpc >/dev/null 2>&1; then
groupdel webui4frpc || true
fi
fi
exit 0

10
packaging/deb/prerm Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# webui4frpc deb 卸载前脚本:停服务、撤 enable
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl stop webui4frpc.service || true
systemctl disable webui4frpc.service || true
fi
exit 0

187
packaging/macos/build_pkg.sh Executable file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env bash
#
# 在 Linux 上手工组装 macOS .pkg (flat package)。
# macOS 的 pkgbuild/productbuild 不可用,因此用 xar + mkbom 手搓 flat package:
#
# archive.xar
# ├── Distribution 安装器界面描述 (choices / title)
# └── webui4frpc.pkg/
# ├── PackageInfo 组件元数据 + postinstall 声明
# ├── Bom 文件清单 (mkbom 生成)
# ├── Payload 根文件系统 cpio.gz
# └── Scripts 安装脚本 cpio.gz
#
# 用法: build_pkg.sh <version> <arch:amd64|arm64> <binary> <outfile>
set -euo pipefail
VERSION="$1"; ARCH="$2"
# xar 与 cpio 都在临时目录里执行,输入输出必须先转成绝对路径
BINARY="$(cd "$(dirname "$3")" && pwd)/$(basename "$3")"
mkdir -p "$(dirname "$4")"
OUTFILE="$(cd "$(dirname "$4")" && pwd)/$(basename "$4")"
case "$ARCH" in
amd64) PKG_ARCH="x86_64" ;;
arm64) PKG_ARCH="arm64" ;;
*) echo "未知架构: $ARCH" >&2; exit 1 ;;
esac
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ROOT="$WORK/root"
SCRIPTS="$WORK/scripts"
FLAT="$WORK/flat"
PKGDIR="$FLAT/webui4frpc.pkg"
mkdir -p "$ROOT/usr/local/bin" "$ROOT/usr/local/etc/webui4frpc" "$SCRIPTS" "$PKGDIR"
# ---- payload 根文件系统 ----
install -m 0755 "$BINARY" "$ROOT/usr/local/bin/webui4frpc"
cat > "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env" <<'ENVEOF'
# webui4frpc runtime configuration (macOS)
# 请修改密码后再投入使用;改完执行:
# sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
ENVEOF
chmod 0644 "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env"
# ---- launchd plist ----
mkdir -p "$ROOT/Library/LaunchDaemons"
cat > "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist" <<'PLISTEOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jianfeeeee.webui4frpc</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/webui4frpc-launch</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/usr/local/var/log/webui4frpc.log</string>
<key>StandardErrorPath</key>
<string>/usr/local/var/log/webui4frpc.err.log</string>
<key>WorkingDirectory</key>
<string>/usr/local/var/lib/webui4frpc</string>
</dict>
</plist>
PLISTEOF
chmod 0644 "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist"
# launchd 不读 EnvironmentFile,用小包装脚本 source 配置再 exec
cat > "$ROOT/usr/local/bin/webui4frpc-launch" <<'LAUNCHEOF'
#!/bin/sh
# 读取 /usr/local/etc/webui4frpc/webui4frpc.env 后启动 webui4frpc
set -e
ENV_FILE=/usr/local/etc/webui4frpc/webui4frpc.env
W4F_ADDR=0.0.0.0:7500
W4F_USER=admin
W4F_PASSWORD=admin
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
exec /usr/local/bin/webui4frpc \
-addr "$W4F_ADDR" \
-user "$W4F_USER" \
-password "$W4F_PASSWORD" \
-workdir /usr/local/var/lib/webui4frpc
LAUNCHEOF
chmod 0755 "$ROOT/usr/local/bin/webui4frpc-launch"
# ---- postinstall ----
cat > "$SCRIPTS/postinstall" <<'POSTEOF'
#!/bin/sh
set -e
mkdir -p /usr/local/var/lib/webui4frpc /usr/local/var/log
chmod 750 /usr/local/var/lib/webui4frpc
PLIST=/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist
chown root:wheel "$PLIST"
chmod 644 "$PLIST"
# 覆盖安装时先卸载旧的
launchctl bootout system "$PLIST" 2>/dev/null || true
launchctl bootstrap system "$PLIST" 2>/dev/null || \
launchctl load -w "$PLIST" 2>/dev/null || true
exit 0
POSTEOF
chmod 0755 "$SCRIPTS/postinstall"
# ---- Payload / Scripts (cpio.gz) ----
( cd "$ROOT" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
| gzip -9 > "$PKGDIR/Payload"
( cd "$SCRIPTS" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
| gzip -9 > "$PKGDIR/Scripts"
# ---- Bom ----
mkbom -u 0 -g 80 "$ROOT" "$PKGDIR/Bom"
NFILES=$(find "$ROOT" | wc -l)
INSTALL_KB=$(du -sk "$ROOT" | cut -f1)
# ---- PackageInfo ----
cat > "$PKGDIR/PackageInfo" <<INFOEOF
<?xml version="1.0" encoding="utf-8"?>
<pkg-info format-version="2"
identifier="com.jianfeeeee.webui4frpc"
version="$VERSION"
install-location="/"
auth="root"
relocatable="false"
overwrite-permissions="true">
<payload installKBytes="$INSTALL_KB" numberOfFiles="$NFILES"/>
<scripts>
<postinstall file="./postinstall"/>
</scripts>
<bundle-version/>
</pkg-info>
INFOEOF
# ---- Distribution ----
cat > "$FLAT/Distribution" <<DISTEOF
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>webui4frpc $VERSION</title>
<organization>com.jianfeeeee</organization>
<options customize="never" require-scripts="false" hostArchitectures="$PKG_ARCH"/>
<domains enable_localSystem="true" enable_anywhere="false" enable_currentUserHome="false"/>
<volume-check>
<allowed-os-versions><os-version min="11.0"/></allowed-os-versions>
</volume-check>
<welcome language="zh_CN" mime-type="text/plain">webui4frpc — 可视化 frpc 控制器
安装内容:
/usr/local/bin/webui4frpc 主程序(内嵌 Web 界面)
/usr/local/etc/webui4frpc/webui4frpc.env 配置文件
/Library/LaunchDaemons/…webui4frpc.plist 开机自启服务
安装完成后服务自动启动,访问 http://127.0.0.1:7500
默认凭据 admin/admin —— 请立即修改 webui4frpc.env 中的密码。</welcome>
<choices-outline>
<line choice="default">
<line choice="com.jianfeeeee.webui4frpc"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="com.jianfeeeee.webui4frpc" visible="false">
<pkg-ref id="com.jianfeeeee.webui4frpc"/>
</choice>
<pkg-ref id="com.jianfeeeee.webui4frpc"
version="$VERSION"
onConclusion="none">#webui4frpc.pkg</pkg-ref>
</installer-gui-script>
DISTEOF
# ---- 打成 xar (Distribution 必须是第一个成员) ----
rm -f "$OUTFILE"
( cd "$FLAT" && xar --compression none -cf "$OUTFILE" Distribution webui4frpc.pkg )
echo "已生成: $OUTFILE ($(du -h "$OUTFILE" | cut -f1))"

View File

@ -0,0 +1,82 @@
Name: webui4frpc
Version: %{?_w4f_version}%{!?_w4f_version:0.1.2}
Release: 1%{?dist}
Summary: Visual frpc controller with token-ring cluster
License: MIT
URL: https://gitcode.com/JianFeeeee/webui4frpc
BuildArch: %{?_w4f_arch}%{!?_w4f_arch:x86_64}
Requires(pre): shadow-utils
%{?systemd_requires}
BuildRequires: systemd-rpm-macros
%description
A web UI to manage FRP forwards across a cluster of nodes. Ships as a single
statically-linked binary with an embedded Vue frontend. Includes a token-ring
based cluster for distributed forward management, NIC network load sampling,
and audit-log CSV export.
%install
install -D -m 0755 %{_w4f_bin} %{buildroot}%{_bindir}/webui4frpc
install -D -m 0644 %{_w4f_service} %{buildroot}%{_unitdir}/webui4frpc.service
install -D -m 0640 %{_w4f_env} %{buildroot}%{_sysconfdir}/webui4frpc/webui4frpc.env
install -D -m 0644 %{_w4f_readme} %{buildroot}%{_docdir}/webui4frpc/README.md
install -d -m 0750 %{buildroot}%{_sharedstatedir}/webui4frpc
%pre
getent group webui4frpc >/dev/null || groupadd --system webui4frpc
getent passwd webui4frpc >/dev/null || \
useradd --system --gid webui4frpc \
--home-dir %{_sharedstatedir}/webui4frpc --no-create-home \
--shell /sbin/nologin \
--comment "webui4frpc service account" webui4frpc
exit 0
%post
%systemd_post webui4frpc.service
chown -R webui4frpc:webui4frpc %{_sharedstatedir}/webui4frpc || :
chown root:webui4frpc %{_sysconfdir}/webui4frpc/webui4frpc.env || :
cat <<'EOF'
webui4frpc 已安装。
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
数据: /var/lib/webui4frpc
服务: systemctl enable --now webui4frpc
界面: http://<本机地址>:7500
EOF
%preun
%systemd_preun webui4frpc.service
%postun
%systemd_postun_with_restart webui4frpc.service
if [ $1 -eq 0 ]; then
rm -rf %{_sharedstatedir}/webui4frpc
getent passwd webui4frpc >/dev/null && userdel webui4frpc || :
getent group webui4frpc >/dev/null && groupdel webui4frpc || :
fi
%files
%{_bindir}/webui4frpc
%{_unitdir}/webui4frpc.service
%dir %{_sysconfdir}/webui4frpc
%config(noreplace) %attr(0640,root,webui4frpc) %{_sysconfdir}/webui4frpc/webui4frpc.env
%dir %attr(0750,webui4frpc,webui4frpc) %{_sharedstatedir}/webui4frpc
%doc %{_docdir}/webui4frpc/README.md
%changelog
* Wed Sep 03 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.2-1
- 修复窄屏底部导航栏错位到顶部:顶栏的 backdrop-filter 会为
position:fixed 后代创建包含块,使 tab bar 的 bottom:0 相对顶栏而
非视口定位。窄屏下关掉顶栏模糊,改用近实色底。
* Wed Sep 02 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.1-1
- 修复令牌环冻结:心跳到达时复活被误判 offline 的前驱;
全环无可达后继时保留 inflight,让 leader 超时重发令牌。
- WebUI 窄屏适配:侧栅转底部 tab bar,各页响应式重排。
* Fri Aug 28 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.0-1
- 首次发布:per-forward worker 模型、NIC 负载采样、审计 CSV 导出、
session 登录与三级角色。

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JianFeeeee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

268
packaging/windows/README.md Normal file
View File

@ -0,0 +1,268 @@
# webui4frpc
> **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。支持多节点组成令牌环集群,协同分发转发任务。
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
## 特性
### 画布配置
- **Scratch 风格画布**:本地转发项(local)与远程服务器(remote)可视化连线,一个 local 可连多个 remote,一个 remote 可连多个 local
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
- **画布冲突检查**:保存时自动检测远程端口/域名重复,避免配置冲突
- **边开关与分组**:画布边上可一键禁用/启用单条转发,可打分组标签(状态页按分组管理、一键启停整组)
- **Del 键/× 按钮删除**:选中连线按 Delete 键或点击端口标签红 × 删除,确认后标记脏
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
- **画布导入导出**:支持 JSON 信封备份/恢复(含 provenance 元数据)
### frpc 能力
- **代理类型**:tcp(完整)、http/https(customDomains / subdomain / locations / basicAuth / headerRewrite)
- **传输参数**:useEncryption / useCompression / bandwidthLimit / poolCount / transport.protocol(tcp/quic/kcp/websocket)/ TLS
- **负载均衡与健康检查**:lbGroup + healthCheck 渲染输出;状态页通过 frpc admin API 拉取 per-proxy 真实状态(running / check failed / wait start …)
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc,或手动指定二进制路径
### 集群模式(令牌环)
- **令牌环协议**:若干 webui4frpc 节点组成协作网络;令牌按固定顺序传递,每节点持完整集群信息,单轮即全网收敛
- **负载最低者摘取**:新转发任务附加在令牌中,由负载最低的节点自行摘取并创建 frpc worker
- 主负载信号:当前节点拥有的转发数(权重 ×100)
- 次级信号:**NIC 网络接口利用率**(从 `/proc/net/dev` 增量 + `/sys/class/net/*/speed` 计算,排除了 lo/docker/veth 等虚拟接口)
- 同转发数节点间由内存占用 + 网络饱和度打破平局
- **per-forward 独立进程**:每条转发拥有独立的 frpc 配置文件和 frpc 进程(worker 键 = `local~remote~port`),单条转发故障不影响兄弟,消除旧模型的多节点 `proxy already exists` 冲突
- **容错自愈**:leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂;leader 宕机 → 上邻居晋升为新 leader
- **宕机自动重联**:节点宕机重启后通过缓存的集群拓扑(对端地址 + 准入密钥)自动重联,显式脱离集群的节点不自动重联
- **准入密钥**:每节点有 nodeKey,新节点加入需提供对端密钥;密钥随令牌环交换,全节点互通
- **集群二进制分发**:节点间优先互传 frpc 二进制,外部 URL 兜底
### 认证与权限
- **Session-cookie 登录 + Basic Auth**:UI 使用 session-cookie(支持登出),API 保持 Basic Auth + Bearer API Key 兼容
- **三级角色**:superadmin(账号管理)、admin(全功能除账号)、viewer(只读状态/集群/审计导出)
- **用户管理**:superadmin 角色创建/管理用户,bcrypt 存储,flag 内置账号为 system 只读
- **API Key**:read / write / admin 三级 scope,`w4f_` 前缀,sha256 存储,创建时明文仅展示一次
- **审计导出**:read 级 view 即可导出 CSV(账号登录记录 / API Key 使用记录 / 集群操作日志 / worker 运行日志)
## 构建
### 后端(Go 1.25+)
```bash
go build -o webui4frpc ./cmd/webui4frpc
```
### 前端(Node 20+)
```bash
cd web && npm install && npm run build
```
前端产物在 `web/dist`,需拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制:
```bash
rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
```
一键全量构建(dist 不进入 git,编译前必须构建前端):
```bash
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
```
## 安装
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.1-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo systemctl restart webui4frpc
```
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.1-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo systemctl enable --now webui4frpc
```
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`。
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
### Windows
双击 `webui4frpc-0.1.1-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
- 开始菜单生成启动快捷方式与卸载项
- 自动添加防火墙入站规则
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.1-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.1-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
```bash
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
```
> 该 `.pkg` 未经 Apple 公证(notarization)。首次打开若被 Gatekeeper 拦下,
> 在「系统设置 → 隐私与安全」选择仍要打开。
### 免安装(tar.gz / zip)
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.1-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```
下载后可用 `SHA256SUMS` 校验完整性:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## 运行
从源码构建后直接跑:
```bash
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
```
打开 <http://127.0.0.1:7500/> ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。
### 参数
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `-addr` | `127.0.0.1:7500` | 监听地址 |
| `-user` / `-password` | `admin` / `admin` | Basic 认证(自动同步为 system 管理员账号) |
| `-workdir` | `./webui-frpc` | 数据目录(db / 配置 / 日志 / bin) |
| `-bin` | 空 | 初始 frpc 二进制路径(可选,首次启动写入 settings) |
| `-frpc` | 空 | worker 默认 frpc 路径(settings 无值时的回退) |
| `-peer` | 空 | 集群对端地址 `host:port`(可重复,仅首次引导加入用) |
| `-join-key` | `$W4F_JOIN_KEY` | 集群准入密钥 = 对端节点的 nodeKey(与 `-peer` 配合使用) |
环境变量 `W4F_HOST` 可覆盖节点对外可达的主机名(Docker DNS / 通配监听场景用)。
### 启动行为
节点启动时的集群引导优先级:
1. **缓存拓扑优先**:读取持久化的 `ClusterPeers`(宕机前最后令牌周期保存的对端列表),逐个尝试 rejoin
2. **`-peer` 引导**:无缓存拓扑时,用 `-peer` + `-join-key` 加入指定对端(10s 重试、5min 超时)
3. **新建集群**:无缓存无 `-peer` → `CreateCluster` 成为独立 leader
显式脱离集群(detachAsStandalone)会清除 `ClusterPeers` → 重启后不自动重联。
## 数据目录
```
<workdir>
├── manager.db # SQLite(locals / remotes / links / settings / users / api_keys)
├── configs/<local>~<remote>~<port>.json # 每条转发的独立 frpc 配置(per-forward 模型)
├── logs/<local>~<remote>~<port>.log # 每条转发的独立 worker 日志(按大小轮转)
└── bin/frpc-<v>/frpc # 一键安装的 frpc
```
## 架构
```
webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
├── cmd/webui4frpc 入口:flag 解析 → store/process/cluster 装配 → HTTP 服务 + 生命周期
├── internal/
│ ├── store SQLite 持久化(locals / remotes / links / settings / users / api_keys / sessions)
│ ├── canvas 画布模型(local / remote / link 多对多)
│ ├── render 渲染 frpc JSON 配置(per-forward 单 proxy + 高级参数 + LB/健康检查)
│ ├── process worker 进程管理(spawn/stop/restart/自愈/日志轮转,worker 键 = local~remote~port)
│ ├── install 一键下载官方 frpc(GitHub Releases,含解压安全防护)
│ ├── cluster 令牌环协议(ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发 / NIC 负载采样)
│ └── httpapi REST API + 静态资源(auth/session / canvas / forwards / cluster / users / audit 导出)
└── web/ Vue 3 SPA(Element Plus + VueFlow 画布 + Pinia)
└── src/views/ 状态总览 / 连接配置(画布)/ 集群 / 设置 / 账号与密钥
```
## API
所有接口前缀 `/api/manager`。UI 使用 session-cookie 登录(`/login` `/logout`),API 兼容 Basic Auth 或 Bearer API Key。权限分 read / write / admin 三级,角色 superadmin / admin / viewer 映射到不同 UI 可见性与操作能力。
| 类别 | 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- | --- |
| **状态** | GET | `/status` | read | 总览(版本/服务/节点/转发/profiles) |
| **画布** | GET/PUT | `/canvas` | read/write | 读写完整画布 |
| | GET | `/canvas/export` | read | 导出画布备份 |
| | POST | `/canvas/import` | write | 导入画布 |
| **转发** | POST | `/forwards/start\|stop` | write | 启停单条转发 |
| | POST | `/forwards/group/start\|stop` | write | 启停整组 |
| | POST | `/forwards/assign` | write | 修改转发分组 |
| | POST | `/forwards/group/delete` | write | 删除分组 |
| **单资源** | PUT/DELETE | `/locals[/{name}]` | write | 增删 local |
| | PUT/DELETE | `/remotes[/{name}]` | write | 增删 remote |
| | POST/DELETE | `/links[/{id}]` | write | 增删 link |
| **Profile** | POST | `/profiles/{name}/start\|stop\|restart` | write | worker 启停重启 |
| | GET | `/profiles/{name}/config\|logs` | read | 配置/日志 |
| **设置** | GET/PUT | `/settings` | read/write | 运行策略 |
| | GET | `/binary/status` | read | frpc 路径 |
| | POST | `/binary/install` | write | 安装 frpc |
| **集群** | GET | `/cluster/ring` | read | 令牌环快照 |
| | POST | `/cluster/create` | write | 创建独立集群 |
| | POST | `/cluster/join-ring` | write | 加入集群(本节点发起) |
| | POST | `/cluster/join` | write | 接收新节点加入 |
| | POST | `/cluster/task` | write | 提交转发任务到环 |
| | POST | `/cluster/node-remove` | write | 移除节点 |
| | POST | `/cluster/token` | write | 令牌中继 + 心跳 |
| | GET | `/cluster/nodes` | read | 集群节点 + 缓存 |
| | GET/POST | `/cluster/cache` | read/write | 二进制缓存管理 |
| | GET | `/node/logs` | read | 本节点 worker 日志 |
| | GET | `/cluster/logs/export` | read | 导出全节点 worker 日志(JSON bundle) |
| **审计导出** | GET | `/audit/users.csv` | read | 账号清单 CSV(含最后登录) |
| | GET | `/audit/apikeys.csv` | read | API Key 清单 CSV(含最后使用/过期) |
| | GET | `/audit/cluster-log.csv` | read | 集群操作日志 CSV(时间线 + 原始载荷) |
| | GET | `/audit/worker-logs.csv` | read | 全节点 worker 运行日志 CSV(逐行展开) |
| | GET | `/frpc/{version}` | read | 节点间 frpc 二进制分发 |
| **认证** | POST | `/login` | 无 | 登录(表单凭证 → session cookie) |
| | POST | `/logout` | 无 | 登出(清除 session) |
| | GET | `/me` | read | 当前身份 |
| | GET/POST | `/users` | admin | 用户列表/创建 |
| | PUT/DELETE | `/users/{name}` | admin | 修改/删除用户 |
| | GET/POST | `/apikeys` | admin | API Key 列表/创建 |
| | DELETE | `/apikeys/{id}` | admin | 吊销 API Key |
> 集群管理 API 的详细说明(请求体、响应格式、错误码、示例)见 [docs/cluster-api.md](docs/cluster-api.md)。
## 集群模式
多个 webui4frpc 节点可组成令牌环集群协同工作:
- **创建集群**:首个节点 `CreateCluster` 成为 leader,生成准入密钥(nodeKey)
- **加入集群**:在集群页复制对端的 nodeKey,填入「加入集群」对话框(对端地址 + 密钥)
- **任务分发**:在任意节点画布上配置 `localOnly=false` 的转发并保存 → 画布差异判断生成命令 → 令牌携带 → 负载最低节点摘取 → 拉起 frpc worker
- **容错**:节点宕机 → 自动检测 → 环自愈(跳过死亡节点 / leader failover);重启 → 自动重联
- **退出集群**:在集群页「退出集群」→ 本节点脱离为 standalone,不自动重联
集群页展示:环拓扑(self/leader 标记)、令牌轮次、节点负载、待办任务、活跃转发拓扑(含 owner 归属)、增量事件日志。
## 测试
```bash
# 后端
go test ./...
# 前端类型检查
cd web && npm run type-check
```

View File

@ -0,0 +1,162 @@
; webui4frpc Windows 安装程序 (NSIS)
;
; 由 scripts/build_installers.sh 调用,需在命令行传入:
; -DVERSION=0.1.0 -DARCH=amd64 -DBINSRC=<webui4frpc.exe 路径> -DOUTFILE=<输出 exe>
!include "MUI2.nsh"
!include "LogicLib.nsh"
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef ARCH
!define ARCH "amd64"
!endif
Name "webui4frpc ${VERSION}"
OutFile "${OUTFILE}"
Unicode true
RequestExecutionLevel admin
InstallDir "$PROGRAMFILES64\webui4frpc"
InstallDirRegKey HKLM "Software\webui4frpc" "InstallDir"
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "webui4frpc"
VIAddVersionKey "FileDescription" "Visual frpc controller with token-ring cluster"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "LegalCopyright" "JianFeeeee"
!define MUI_ABORTWARNING
!insertmacro MUI_PAGE_LICENSE "LICENSE.txt"
!insertmacro MUI_PAGE_DIRECTORY
Page custom ConfigPage ConfigPageLeave
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "English"
Var Dialog
Var LblAddr
Var TxtAddr
Var LblUser
Var TxtUser
Var LblPass
Var TxtPass
Var CfgAddr
Var CfgUser
Var CfgPass
Function .onInit
StrCpy $CfgAddr "0.0.0.0:7500"
StrCpy $CfgUser "admin"
StrCpy $CfgPass "admin"
FunctionEnd
Function ConfigPage
!insertmacro MUI_HEADER_TEXT "服务配置" "设置监听地址与登录凭据"
nsDialogs::Create 1018
Pop $Dialog
${If} $Dialog == error
Abort
${EndIf}
${NSD_CreateLabel} 0 0 100% 12u "监听地址 (host:port):"
Pop $LblAddr
${NSD_CreateText} 0 14u 100% 12u "$CfgAddr"
Pop $TxtAddr
${NSD_CreateLabel} 0 34u 100% 12u "管理员用户名:"
Pop $LblUser
${NSD_CreateText} 0 48u 100% 12u "$CfgUser"
Pop $TxtUser
${NSD_CreateLabel} 0 68u 100% 12u "管理员密码(请勿沿用默认值):"
Pop $LblPass
${NSD_CreatePassword} 0 82u 100% 12u "$CfgPass"
Pop $TxtPass
nsDialogs::Show
FunctionEnd
Function ConfigPageLeave
${NSD_GetText} $TxtAddr $CfgAddr
${NSD_GetText} $TxtUser $CfgUser
${NSD_GetText} $TxtPass $CfgPass
FunctionEnd
Section "webui4frpc" SecMain
SetOutPath "$INSTDIR"
File "/oname=webui4frpc.exe" "${BINSRC}"
File "README.md"
File "LICENSE.txt"
CreateDirectory "$INSTDIR\data"
; 启动脚本,承载安装时选择的配置
FileOpen $0 "$INSTDIR\start-webui4frpc.cmd" w
FileWrite $0 "@echo off$\r$\n"
FileWrite $0 "rem webui4frpc 启动脚本(由安装程序生成)$\r$\n"
FileWrite $0 "cd /d %~dp0$\r$\n"
FileWrite $0 'webui4frpc.exe -addr $CfgAddr -user $CfgUser -password $CfgPass -workdir "%~dp0data"$\r$\n'
FileClose $0
WriteRegStr HKLM "Software\webui4frpc" "InstallDir" "$INSTDIR"
WriteRegStr HKLM "Software\webui4frpc" "Version" "${VERSION}"
; 添加/删除程序
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"DisplayName" "webui4frpc ${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"Publisher" "JianFeeeee"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"URLInfoAbout" "https://gitcode.com/JianFeeeee/webui4frpc"
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"NoModify" 1
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
"NoRepair" 1
WriteUninstaller "$INSTDIR\uninstall.exe"
; 开始菜单
CreateDirectory "$SMPROGRAMS\webui4frpc"
CreateShortcut "$SMPROGRAMS\webui4frpc\webui4frpc.lnk" \
"$INSTDIR\start-webui4frpc.cmd" "" "$INSTDIR\webui4frpc.exe" 0
CreateShortcut "$SMPROGRAMS\webui4frpc\打开管理界面.lnk" \
"http://127.0.0.1:7500"
CreateShortcut "$SMPROGRAMS\webui4frpc\卸载 webui4frpc.lnk" "$INSTDIR\uninstall.exe"
; 防火墙放行(失败不阻断安装)
nsExec::ExecToLog 'netsh advfirewall firewall add rule name="webui4frpc" \
dir=in action=allow program="$INSTDIR\webui4frpc.exe" enable=yes profile=any'
SectionEnd
Section "Uninstall"
nsExec::ExecToLog 'netsh advfirewall firewall delete rule name="webui4frpc"'
Delete "$INSTDIR\webui4frpc.exe"
Delete "$INSTDIR\start-webui4frpc.cmd"
Delete "$INSTDIR\README.md"
Delete "$INSTDIR\LICENSE.txt"
Delete "$INSTDIR\uninstall.exe"
; data 目录保留(含数据库与日志),由用户手动删除
MessageBox MB_YESNO|MB_ICONQUESTION \
"是否同时删除数据目录 $INSTDIR\data(数据库、转发配置、日志)?" \
IDNO KeepData
RMDir /r "$INSTDIR\data"
KeepData:
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\webui4frpc\*.lnk"
RMDir "$SMPROGRAMS\webui4frpc"
DeleteRegKey HKLM "Software\webui4frpc"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc"
SectionEnd

131
scripts/build_installers.sh Executable file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env bash
#
# 从 release.sh 的交叉编译产物构建原生安装包:
# - linux amd64/arm64 → .deb
# - linux amd64/arm64 → .rpm
# - windows amd64/arm64 → NSIS .exe 安装器
# - darwin amd64/arm64 → macOS .pkg
#
# 用法: build_installers.sh <version> [arch...]
# arch 默认: linux amd64 arm64
#
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
set -euo pipefail
VERSION="${1:-0.1.2}"
VERSION="${VERSION#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
PKG="$ROOT/packaging"
# 工具检测(缺工具则给出明确提示后跳过该平台)
have() { command -v "$1" >/dev/null 2>&1; }
build_deb() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/linux-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 deb"; return; }
local deb=/tmp/w4f-deb-build; rm -rf "$deb"
mkdir -p "$deb/DEBIAN" "$deb/usr/bin" "$deb/etc/webui4frpc" \
"$deb/lib/systemd/system" "$deb/usr/share/doc/webui4frpc"
# control
cat > "$deb/DEBIAN/control" <<EOF
Package: webui4frpc
Version: $VERSION
Section: net
Priority: optional
Architecture: $arch
Maintainer: JianFeeeee <jianfeeeee@gitcode>
Description: visual frpc controller with token-ring cluster
A web UI to manage FRP forwards across a cluster of nodes. Single
statically-linked binary with an embedded Vue frontend. Includes a
token-ring based cluster for distributed forward management, NIC network
load sampling, and audit-log CSV export.
Homepage: https://gitcode.com/JianFeeeee/webui4frpc
EOF
install -m 0755 "$bindir/webui4frpc" "$deb/usr/bin/webui4frpc"
install -m 0644 "$PKG/common/webui4frpc.service" "$deb/lib/systemd/system/webui4frpc.service"
install -m 0640 "$PKG/common/webui4frpc.env" "$deb/etc/webui4frpc/webui4frpc.env"
install -m 0644 "$ROOT/README.md" "$deb/usr/share/doc/webui4frpc/README.md"
cat > "$deb/usr/share/doc/webui4frpc/copyright" <<'EOF'
webui4frpc - visual frpc controller
Copyright (C) 2026 JianFeeeee
SPDX-License-Identifier: MIT
EOF
install -m 0755 "$PKG/deb/postinst" "$deb/DEBIAN/postinst"
install -m 0755 "$PKG/deb/prerm" "$deb/DEBIAN/prerm"
install -m 0755 "$PKG/deb/postrm" "$deb/DEBIAN/postrm"
echo "/etc/webui4frpc/webui4frpc.env" > "$deb/DEBIAN/conffiles"
( cd "$deb" && find usr etc lib -type f -exec md5sum {} \; > DEBIAN/md5sums )
dpkg-deb -b --root-owner-group "$deb" "$OUT/webui4frpc-${VERSION}-linux-${arch}.deb" >/dev/null
echo " ✔ .deb linux/$arch"
}
build_rpm() {
local arch="$1" rarch key="$2" bindir
case "$arch" in amd64) rarch=x86_64;; arm64) rarch=aarch64;; *) return;; esac
bindir="$OUT/bin/linux-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 rpm"; return; }
local R=/tmp/w4f-rpmbuild; rm -rf "$R"; mkdir -p "$R"/{BUILD,RPMS,SPECS,SOURCES}
rpmbuild -bb "$PKG/rpm/webui4frpc.spec" --nodeps --target "$rarch" \
--define "_topdir $R" \
--define "_w4f_version $VERSION" \
--define "_w4f_arch $rarch" \
--define "_w4f_bin $bindir/webui4frpc" \
--define "_w4f_service $PKG/common/webui4frpc.service" \
--define "_w4f_env $PKG/common/webui4frpc.env" \
--define "_w4f_readme $ROOT/README.md" \
--define "_unitdir /usr/lib/systemd/system" \
--define "_sharedstatedir /var/lib" >/tmp/w4f-rpm.log 2>&1 || true
cp "$R"/RPMS/$rarch/*.rpm "$OUT/webui4frpc-${VERSION}-linux-${rarch}.rpm" 2>/dev/null \
&& echo " ✔ .rpm linux/$rarch" || { echo " ! rpm 失败: $(tail -3 /tmp/w4f-rpm.log)"; }
}
build_nsis() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/windows-$arch"
[ -f "$bindir/webui4frpc.exe" ] || { echo " ! 缺二进制 windows-$arch,跳过 NSIS"; return; }
[ -f "$PKG/windows/LICENSE.txt" ] || { echo " ! 缺 $PKG/windows/LICENSE.txt"; return; }
cp -f "$ROOT/README.md" "$PKG/windows/README.md"
makensis "-DVERSION=$VERSION" "-DARCH=$arch" \
"-DBINSRC=$bindir/webui4frpc.exe" \
"-DOUTFILE=$OUT/webui4frpc-${VERSION}-windows-${arch}-setup.exe" \
"$PKG/windows/webui4frpc.nsi" >/tmp/w4f-nsis.log 2>&1 \
&& echo " ✔ NSIS setup.exe windows/$arch" \
|| { echo " ! NSIS 失败: $(tail -4 /tmp/w4f-nsis.log)"; }
}
build_pkg() {
local arch="$1" key="$2" bindir
bindir="$OUT/bin/darwin-$arch"
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 darwin-$arch,跳过 pkg"; return; }
"$PKG/macos/build_pkg.sh" "$VERSION" "$arch" \
"$bindir/webui4frpc" \
"$OUT/webui4frpc-${VERSION}-darwin-${arch}.pkg" >/dev/null \
&& echo " ✔ .pkg darwin/$arch" \
|| echo " ! macOS pkg 失败 darwin/$arch"
}
ARCHES=("${@:2}")
[ ${#ARCHES[@]} -eq 0 ] && ARCHES=(amd64 arm64)
echo "==> 原生安装包 (v$VERSION):"
for arch in "${ARCHES[@]}"; do
key="linux-$arch"
if have dpkg-deb; then build_deb "$arch" "$key"; else echo " . dpkg-deb 不可用,跳过 deb"; fi
if have rpmbuild; then build_rpm "$arch" "$key"; else echo " . rpmbuild 不可用,跳过 rpm"; fi
done
if have makensis; then
for arch in "${ARCHES[@]}"; do build_nsis "$arch" "windows-$arch"; done
else
echo " . makensis 不可用,跳过 windows setup.exe"
fi
if have xar && have mkbom && have cpio; then
for arch in "${ARCHES[@]}"; do build_pkg "$arch" "darwin-$arch"; done
else
echo " . xar/mkbom 不可用,跳过 macOS pkg"
fi
echo ""
echo "==> 安装包产物:"
ls -lh "$OUT"/*.deb "$OUT"/*.rpm "$OUT"/*-setup.exe "$OUT"/*.pkg 2>/dev/null || true

150
scripts/release.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
#
# webui4frpc 多平台发布脚本
#
# 用法:
# ./scripts/release.sh [version] # 默认 version=0.1.2
#
# 行为:
# 1. 构建前端 (web/dist) 并同步到 internal/httpapi/dist (go:embed 用)
# 2. 交叉编译全平台单二进制 (linux/windows/darwin × amd64/arm64)
# 3. 打包各平台安装包:
# - linux/darwin: tar.gz (二进制 + README + systemd 模板)
# - windows: zip (exe + README)
# 4. 生成 SHA256SUMS
# 5. 输出到 dist/artifacts/<version>/
#
# 环境变量:
# GOPROXY_OVERRIDE 覆盖 go module 代理 (默认 goproxy.cn,direct, 走本地 7890 代理)
# NO_BUILD_FRONTEND 设为 1 跳过前端构建 (用现有 dist)
set -euo pipefail
VERSION="${1:-0.1.2}"
VERSION="${VERSION#v}" # 去掉可能的前导 v
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
GOFLAGS_ENV=""
# ---- 0. Go module 代理 (内网需走本地代理访问 goproxy.cn) ----
if [ -n "${GOPROXY_OVERRIDE:-}" ]; then
GOFLAGS_ENV="GOPROXY=${GOPROXY_OVERRIDE}"
else
GOFLAGS_ENV="GOPROXY=https://goproxy.cn,direct"
fi
# 若本地 7890 代理存在则导出 (模块下载走代理)
if curl -s -o /dev/null -m 2 -x http://127.0.0.1:7890 https://goproxy.cn >/dev/null 2>&1; then
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
fi
echo "==> 版本: $VERSION"
mkdir -p "$OUT"
# ---- 1. 构建前端 ----
if [ "${NO_BUILD_FRONTEND:-0}" != "1" ]; then
echo "==> 构建前端"
( cd "$ROOT/web" && npm run build )
rm -rf "$ROOT/internal/httpapi/dist"
cp -r "$ROOT/web/dist" "$ROOT/internal/httpapi/dist"
else
echo "==> 跳过前端构建 (用现有 dist)"
fi
# ---- 2. 交叉编译全平台 ----
# target 格式: os arch 扩展名 [tar|zip]
TARGETS=(
"linux amd64" "linux arm64"
"windows amd64" "windows arm64"
"darwin amd64" "darwin arm64"
)
declare -A PLATFORM_EXT=(
[linux-amd64]=tgz [linux-arm64]=tgz
[darwin-amd64]=tgz [darwin-arm64]=tgz
[windows-amd64]=zip [windows-arm64]=zip
)
declare -A PLATFORM_BIN=(
[linux-amd64]=webui4frpc [linux-arm64]=webui4frpc
[darwin-amd64]=webui4frpc [darwin-arm64]=webui4frpc
[windows-amd64]=webui4frpc.exe [windows-arm64]=webui4frpc.exe
)
for target in "${TARGETS[@]}"; do
os="${target%% *}"; arch="${target##* }"
key="$os-$arch"
bin="${PLATFORM_BIN[$key]}"
# 每个平台编译到独立子目录,避免 darwin/linux 同名二进制互相覆盖
bindir="$OUT/bin/$key"
mkdir -p "$bindir"
echo "==> 编译 $os/$arch"
env $GOFLAGS_ENV CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath \
-ldflags="-s -w -X webui4frpc/internal/version.Version=$VERSION" \
-o "$bindir/$bin" "$ROOT/cmd/webui4frpc"
done
# ---- 3. 打包各平台 ----
# 整理 systemd 模板
cat > "$OUT/webui4frpc.service.tpl" <<'TPL'
[Unit]
Description=webui4frpc - visual frpc controller (cluster node)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/webui4frpc/webui4frpc -addr 0.0.0.0:7500 -user admin -password admin -workdir /opt/webui4frpc/data
Restart=always
RestartSec=5
LimitNOFILE=65535
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
TPL
for target in "${TARGETS[@]}"; do
os="${target%% *}"; arch="${target##* }"
key="$os-$arch"
bin="${PLATFORM_BIN[$key]}"
ext="${PLATFORM_EXT[$key]}"
base="webui4frpc-${VERSION}-${os}-${arch}"
echo "==> 打包 $key -> $base.$ext"
cp "$ROOT/README.md" "$OUT/README.md"
bindir="$OUT/bin/$key"
# staging 目录: 把二进制 + README (+systemd) 放一起再一次性打包
stage="$OUT/.stage/$key"
rm -rf "$stage"; mkdir -p "$stage"
cp "$bindir/$bin" "$stage/$bin"
cp "$OUT/README.md" "$stage/README.md"
if [ "$ext" = "zip" ]; then
( cd "$stage" && zip -q "$OUT/$base.zip" "$bin" README.md )
else
cp "$OUT/webui4frpc.service.tpl" "$stage/"
( cd "$stage" && tar czf "$OUT/$base.tar.gz" "$bin" README.md webui4frpc.service.tpl )
fi
done
rm -rf "$OUT/.stage"
# ---- 4. 校验和 ----
# 先生成免安装包的校验和;原生安装包构建完后在 4.6 统一重算,
# 保证 SHA256SUMS 覆盖 deb/rpm/setup.exe/pkg(否则用户校验会 "no file was verified")。
# ---- 4.5 原生安装包 (deb/rpm/setup.exe/pkg) ----
if [ "${NO_INSTALLERS:-0}" != "1" ]; then
echo ""
echo "==> 构建原生安装包"
"$ROOT/scripts/build_installers.sh" "$VERSION"
fi
# ---- 4.6 校验和(覆盖全部产物)----
echo ""
echo "==> 生成 SHA256SUMS"
( cd "$OUT" && sha256sum *.tar.gz *.zip *.deb *.rpm *-setup.exe *.pkg 2>/dev/null | tee SHA256SUMS )
# ---- 5. 汇总 ----
echo ""
echo "==> 发布产物位于: $OUT"
ls -lh "$OUT"
echo ""
echo "==> 完成"

88
scripts/upload_assets.py Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""上传 release 资产到 gitcode(两步:取签名 URL → PUT 到 OBS)。
用法: upload_assets.py <version> <token> [file...]
不传 file 时上传该版本目录下全部发布产物:
tar.gz / zip / deb / rpm / pkg / -setup.exe / SHA256SUMS
"""
import json
import os
import sys
import urllib.request
import urllib.parse
REPO = "JianFeeeee/webui4frpc"
API = "https://gitcode.com/api/v5/repos"
def get_upload_url(version: str, token: str, filename: str) -> tuple[str, dict]:
q = urllib.parse.urlencode({"file_name": filename})
url = f"{API}/{REPO}/releases/v{version}/upload_url?{q}"
req = urllib.request.Request(url, headers={"private-token": token})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
return data["url"], data.get("headers", {})
def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
size = os.path.getsize(path)
with open(path, "rb") as f:
body = f.read()
req = urllib.request.Request(url, data=body, method="PUT")
for k, v in headers.items():
req.add_header(k, v)
req.add_header("Content-Length", str(size))
try:
with urllib.request.urlopen(req, timeout=300) as r:
return r.status, r.read().decode("utf-8", "replace")[:300]
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")[:300]
# 发布产物后缀:免安装包 + 原生安装包。
# 注意 -setup.exe 而不是裸 .exe,避开 bin/ 里的裸二进制。
ARTIFACT_SUFFIXES = (
".tar.gz", ".zip", # 免安装
".deb", ".rpm", ".pkg", # linux / macOS 安装包
"-setup.exe", # windows 安装器
)
def is_artifact(name: str) -> bool:
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
version, token = sys.argv[1], sys.argv[2]
outdir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dist", "artifacts", version,
)
files = sys.argv[3:] or sorted(f for f in os.listdir(outdir) if is_artifact(f))
failed = 0
for name in files:
path = os.path.join(outdir, name)
if not os.path.isfile(path):
print(f"skip (missing): {name}")
continue
print(f"==> {name} ({os.path.getsize(path)/1048576:.1f} MiB)")
try:
url, headers = get_upload_url(version, token, name)
except Exception as e: # noqa: BLE001
print(f" upload_url FAILED: {e}")
failed += 1
continue
status, body = put_file(url, headers, path)
ok = 200 <= status < 300
print(f" PUT -> {status} {'OK' if ok else body}")
if not ok:
failed += 1
print(f"\n{'ALL OK' if failed == 0 else f'{failed} FAILED'}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title> <title>webui4frpc</title>
</head> </head>

View File

@ -363,4 +363,153 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
.breadcrumb { padding: 12px 16px 6px; } .breadcrumb { padding: 12px 16px 6px; }
.content { padding: 8px 14px 32px; } .content { padding: 8px 14px 32px; }
} }
/* ---------- narrow screens: sidebar becomes a bottom tab bar ----------
窄屏(≤ 720px)下 210px 侧栅会吃掉手机一半宽度,改为:
- shell 竖向排列,侧栅沉到底部变成固定 tab bar(图标在上文字在下)
- 品牌区/身份区/主题切换折叠进顶部窄条,不占垂直空间
- 内容区预留底部安全区 + tab bar 高度,避开 iOS 手势条
- 每个 tab 至少 44px 高(触控目标下限) */
@media (max-width: 720px) {
.app-shell {
flex-direction: column;
}
/* 顶部窄条:logo + 身份 + 主题,横向排一行 */
.sidebar {
flex: 0 0 auto;
width: 100%;
order: -1;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 10px 12px;
overflow: hidden;
/* 兵家必争:任何子项算错都不得弄出横向滚动条 */
overflow-x: hidden;
border-right: none;
border-bottom: 1px solid var(--w4f-line);
padding-top: max(10px, env(safe-area-inset-top));
/* 关键:backdrop-filter 会为 position:fixed 后代创建【包含块】
(与 transform/filter 同理)。若保留它,下面 .sb-nav 的
bottom:0 就是相对这条顶栏定位而非视口,底部 tab bar 会贴在
顶栏下沿、看起来固定在屏幕顶部。因此窄屏必须关掉顶栏的玻璃
模糊,改用接近实色的底,模糊效果留在 .sb-nav 自己身上
(元素自身的 backdrop-filter 不影响它自己的定位)。 */
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: color-mix(in srgb, var(--w4f-card-solid) 92%, transparent);
}
.sb-brand {
padding: 0;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* 品牌文字必须可收缩:“webui4frpc”是不可断行单词,不给 min-width:0
+ overflow:hidden 的话它的 flex 自动最小尺寸等于 min-content(≨85px),
顶栏就拿不出空间给右侧身份区,溢出为横向滚动。 */
.sb-brand-text { min-width: 0; overflow: hidden; }
.sb-brand-text b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-logo { width: 30px; height: 30px; border-radius: 10px; }
.sb-brand-text b { font-size: 14.5px; }
.sb-brand-text small { display: none; }
/* 导航从侧栅里抽出来,固定到底部 */
.sb-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
flex: none;
flex-direction: row;
gap: 0;
padding: 4px 4px max(4px, env(safe-area-inset-bottom));
background: var(--w4f-card);
border-top: 1px solid var(--w4f-line);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
box-shadow: 0 -4px 20px rgb(0 0 0 / 6%);
}
.sb-i {
flex: 1 1 0;
min-width: 0;
min-height: 44px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 2px;
border-radius: 11px;
/* Keep the tab label at the theme's 11px floor; the 10.5px here (and 9.5px
* at ≤380px) read as noise on a phone. "账号与密钥" is still handled by the
* .label ellipsis (width:100% + overflow:hidden). */
font-size: 11px;
font-weight: 600;
text-align: center;
}
.sb-i .label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-i.active {
/* 底部 tab 不用渐变底,只高亮图标与文字(避免拥挤) */
background: var(--w4f-primary-50);
border-color: transparent;
box-shadow: none;
}
/* 身份与主题折进顶条右侧。
不再用 vw 限宽:vw 含垂直滚动条宽度,会比实际可用内容宽大;而且
max-width 只封顶盒子,内部 flex:0 0 auto 的徒章/按钮仍会撑出去。
改为全链路可收缩(min-width:0 + overflow:hidden),由 flex 自行分配。 */
.sb-foot {
margin-top: 0;
padding-top: 0;
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
}
.sb-identity {
margin-bottom: 0;
padding: 5px 8px;
font-size: 11.5px;
flex: 0 1 auto;
min-width: 0;
max-width: none;
overflow: hidden;
}
.sb-identity .id-name { min-width: 0; max-width: none; }
.sb-theme { margin-bottom: 0; flex: 0 0 auto; }
.sb-theme .sw { width: 22px; height: 22px; border-radius: 7px; }
.main { height: auto; flex: 1; min-height: 0; }
.breadcrumb { padding: 10px 14px 4px; font-size: 14px; }
.content {
padding: 6px 12px 24px;
/* 底部 tab bar(44px + padding)+ 安全区 */
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
/* 极窄(≤ 380px,iPhone SE / 小屏安卓):进一步压缩顶条。
角色徒章是 flex:0 0 auto 不可收缩的(“超级管理员”≨70px),极窄下隐去,
用户名与退出按钮保留。
注意:底部 tab 文字不再随之降到 9.5px —— 它在手机上低于可读下限
(见 .sb-i 的 11px 注释),长标签交给 .label 的省略号处理。 */
@media (max-width: 380px) {
.sb-brand-text b { font-size: 13px; }
.sb-identity { padding: 4px 7px; }
.sb-identity .id-level { display: none; }
.content { padding-left: 10px; padding-right: 10px; }
}
</style> </style>

View File

@ -184,6 +184,20 @@
/* ---- blue theme (ocean × frost) ---- */ /* ---- blue theme (ocean × frost) ---- */
/* ---- box model reset ----
* This app had NO box-sizing reset, so every element using `width: 100%` with
* horizontal padding overflowed by exactly its padding under the default
* content-box model. The narrow-screen top bar was the visible victim:
* `.sidebar { width: 100%; padding: 10px 12px }` rendered 375+24 = 399px wide
* on a 375px viewport (344px at 320px), pushing the theme swatches and logout
* button off the right edge where `overflow-x: hidden` silently clipped them.
* It also made the desktop sidebar 246+28 = 274px wide instead of 246px.
* border-box makes width include padding+border, which every layout here
* already assumes (Element Plus sets it on its own components already). */
*,
*::before,
*::after { box-sizing: border-box; }
html, html,
body, body,
#app { #app {
@ -346,4 +360,14 @@ a { color: var(--w4f-primary-h); }
/* ---- Element Plus dialog tweaks to read as glass ---- */ /* ---- Element Plus dialog tweaks to read as glass ---- */
.el-overlay { backdrop-filter: blur(3px); } .el-overlay { backdrop-filter: blur(3px); }
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; } .el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
/* Dialogs pass a fixed px width (width="420px" / "440px"), which Element Plus
* writes into an INLINE `--el-dialog-width` custom property. On phones that is
* wider than the viewport (420px on a 375px screen), so the dialog's right edge
* — including the header close button — starts off-screen and is only reachable
* by scrolling the overlay horizontally. Clamp it to the viewport; !important
* is required to beat the inline custom property. */
@media (max-width: 720px) {
.el-dialog { --el-dialog-width: calc(100vw - 24px) !important; }
}
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; } .el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }

View File

@ -25,16 +25,17 @@
<VueFlow <VueFlow
v-model:nodes="nodes" v-model:nodes="nodes"
v-model:edges="edges" v-model:edges="edges"
:default-viewport="{ zoom: 0.85 }" :default-viewport="{ zoom: FIT_MAX_ZOOM }"
:min-zoom="0.3" :min-zoom="MIN_ZOOM"
:max-zoom="2" :max-zoom="2"
fit-view-on-init
:delete-key-code="null" :delete-key-code="null"
:nodes-draggable="canWrite" :nodes-draggable="canWrite"
:nodes-connectable="canWrite" :nodes-connectable="canWrite"
:edges-updatable="false" :edges-updatable="false"
:edges-reconnectable="false" :edges-reconnectable="false"
class="flow-canvas" class="flow-canvas"
@init="onFlowInit"
@nodes-initialized="reframe"
@connect="onConnect" @connect="onConnect"
@pane-click="deselectAll" @pane-click="deselectAll"
@edge-click="onEdgeClick" @edge-click="onEdgeClick"
@ -118,7 +119,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@vue-flow/core/dist/style.css' import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css' import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { VueFlow } from '@vue-flow/core' import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@ -134,6 +135,103 @@ const nodes = ref<any[]>([])
const edges = ref<Edge[]>([]) const edges = ref<Edge[]>([])
const loading = ref(false) const loading = ref(false)
// ---- viewport / zoom floor ----
// The canvas is laid out in absolute graph units (local column at x=60, remote
// column at x=760, and BOTH columns grow downward on every added forward), so
// its bounding box is far wider and taller than a phone viewport. Vue Flow's
// `fit-view-on-init` frames that whole box, which on a phone produced a scale of
// ~0.31: node cards measure 248px in graph units but rendered 85px wide at an
// effective font size of ~4.4px — unreadable. Even a 1280px desktop landed at
// ~0.60 (8.4px) with five forwards stacked.
//
// So the canvas is framed by frameCanvas() below, clamped by a zoom floor, and
// the canvas pans when the content no longer fits. 0.65 renders a 240px local
// card at ~156px with a legible ~9px font.
//
// NOTE: `fit-view-on-init` is deliberately NOT set. It resolves on its own
// schedule and overwrote the explicit fit performed here (observed: this code
// measured the unfitted layout, set the viewport, and then the deferred init fit
// landed last and won). Owning the fit outright is the only way to keep it
// deterministic.
//
// The store arrives via the `init` event. That is deliberately preferred over
// `useVueFlow()`: this component is the PARENT of <VueFlow>, and calling
// useVueFlow() in a parent without a shared id can resolve to a different store
// than the one the rendered flow uses. The event cannot misfire that way.
const MIN_ZOOM = 0.65
const FIT_MAX_ZOOM = 1
type FlowStore = {
fitView: (opts?: Record<string, unknown>) => Promise<boolean>
getViewport?: () => { x: number; y: number; zoom: number }
setViewport?: (
t: { x: number; y: number; zoom: number },
opts?: Record<string, unknown>,
) => Promise<boolean>
}
let flowStore: FlowStore | null = null
// frameCanvas fits the graph, then re-anchors it to the top-left padding edge.
//
// Two reasons it is not just fitView():
// 1. the zoom floor (MIN_ZOOM) — fitView alone shrank 5 stacked forwards to
// ~0.31 on a phone, rendering 4.4px text;
// 2. fitView centres the bounding box, which on a narrow screen puts the seam
// between the local and remote columns mid-viewport — the user sees half of
// each. Anchoring the top-left node to the padding edge means whole local
// cards are visible immediately and the remote column is a pan away.
//
// duration:0 on both transitions — we measure node rects in between, and a
// running animation would hand back mid-flight transforms and skew the anchor.
const PANE_PAD_RATIO = 0.06
const frameCanvas = async () => {
if (!flowStore) return
await flowStore.fitView({
padding: 0.12,
minZoom: MIN_ZOOM,
maxZoom: FIT_MAX_ZOOM,
duration: 0,
})
const pane = document.querySelector<HTMLElement>('.vue-flow')
const nodeEls = Array.from(document.querySelectorAll<HTMLElement>('.vue-flow__node'))
if (!pane || !nodeEls.length) return
const paneRect = pane.getBoundingClientRect()
const rects = nodeEls.map((el) => el.getBoundingClientRect())
const dx =
paneRect.left + pane.clientWidth * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.left))
const dy =
paneRect.top + pane.clientHeight * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.top))
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
const vp = flowStore.getViewport?.()
if (!vp) return
await flowStore.setViewport?.({ x: vp.x + dx, y: vp.y + dy, zoom: vp.zoom }, { duration: 0 })
}
// The flow store and the node list become available at different times: the
// store at component init, the nodes only once the async canvas fetch resolves
// (plus a further tick for Vue Flow to measure them). Framing on any single
// signal would fit an EMPTY canvas, so every path calls reframe() and it no-ops
// until there is a store and something to frame.
//
// `nodes-initialized` also fires whenever a node is added later. Auto-framing on
// that would yank the viewport out from under whoever is mid-pan, so the initial
// frame is claimed exactly once; rearranging is the only thing that re-frames on
// demand (see autoLayout).
let framedOnce = false
const reframe = (force = false) => {
if (!flowStore || !nodes.value.length) return
if (framedOnce && !force) return
framedOnce = true
nextTick(() => { void frameCanvas() })
}
const onFlowInit = (store: FlowStore) => {
flowStore = store
reframe()
}
// Background dot-pattern color, resolved from the live theme var so the grid // Background dot-pattern color, resolved from the live theme var so the grid
// recolors on theme switch (vue-flow Background renders pattern-color as an // recolors on theme switch (vue-flow Background renders pattern-color as an
// SVG fill attribute, which can't resolve var() directly — read the computed // SVG fill attribute, which can't resolve var() directly — read the computed
@ -667,7 +765,12 @@ const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'), nodes.value.filter((n) => n.type === 'remote'),
) )
const autoLayout = () => layoutColumns(true) // Re-frame after rearranging, bounded by the same zoom floor so "自动排列" never
// shrinks the cards back into unreadable territory on a narrow screen.
const autoLayout = () => {
layoutColumns(true)
reframe(true)
}
const load = async () => { const load = async () => {
loading.value = true loading.value = true
@ -718,6 +821,9 @@ const load = async () => {
} }
assignLayers() assignLayers()
dirty.value = false dirty.value = false
// The store exists by now but the node list has only just been populated, so
// this is the first moment a fit actually has something to frame.
reframe(true)
} finally { } finally {
loading.value = false loading.value = false
} }
@ -1053,4 +1159,47 @@ onBeforeUnmount(() => {
cursor: grabbing; cursor: grabbing;
} }
} }
/* ---------- narrow screens ----------
画布本质上需要宽屏拖拽,窄屏下只做到“可用”:
- toolbar 七个按钮在 375px 下会溢出,改成标题占行 + 按钮换行平铺
- 画布高度给个下限,否则在短屏上只剩一条缝
- handle 抬到 16px(手指命中 12px 圆点几乎不可能) */
@media (max-width: 720px) {
.canvas-editor { gap: 10px; }
.toolbar {
padding: 11px 12px;
row-gap: 8px;
}
.toolbar-left,
.toolbar-right {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-title {
width: 100%;
margin-right: 0;
font-size: 15.5px;
}
.toolbar .btn {
flex: 1 1 auto;
min-height: 38px;
padding: 6px 10px;
}
.save-hint { width: 100%; }
.flow-wrap { min-height: 62vh; }
/* 触控:连接点与边标签放大,手指才点得到 */
:deep(.vue-flow__handle) {
width: 16px;
height: 16px;
}
:deep(.vue-flow__edge-label) {
padding: 3px 9px;
font-size: 12.5px;
}
}
</style> </style>

View File

@ -481,7 +481,11 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; padding: 4px 0 40px; gap: 14px; } /* Let .content (App.vue) remain the single scroll container — see the note in
* StatusView: a page-level scroller sized by leftover flex space is what froze
* the status page. `.log-list` keeps its own bounded scroll (that one is
* deliberate — a log pane should not push the whole page). */
.cluster-page { display: flex; flex-direction: column; padding: 4px 0 40px; gap: 14px; }
/* hero */ /* hero */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; } .hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
@ -560,4 +564,78 @@ onBeforeUnmount(() => {
/* dialog form */ /* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } } .form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
.form-hint { font-size: 12px; color: $color-text-muted; } .form-hint { font-size: 12px; color: $color-text-muted; }
/* ---------- narrow screens ----------
窄屏下的主要问题:
- ring-node 的 min-width:168px 与环形箭头在 375px 下会横向溢出,且节点横排无意义
- nodeKey 是 32 位 hex,单行放不下会撑宽容器
- log-row 的 lg-detail max-width:60% + 不换行,窄屏几乎全被省略号吃掉
- toolbar / 对话框表单同 StatusView 的问题 */
@media (max-width: 720px) {
.cluster-page { gap: 10px; }
.section { padding: 14px 14px; }
.hero { padding: 14px 15px; gap: 12px; }
.page-title { font-size: 16.5px; }
/* nodeKey 允许折行,不再撑宽容器 */
.nk-value {
font-size: 11px;
word-break: break-all;
white-space: normal;
max-width: 100%;
}
.nk-label { white-space: normal; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
/* 工具栏按钮平分整行 */
.toolbar { padding: 11px 13px; gap: 8px; }
.toolbar > .w4f-btn { flex: 1 1 auto; min-height: 40px; }
.act-hint { width: 100%; }
/* 环拓扑改竖向:节点卡占整宽,箭头旋转 90° 指下 */
.ring-line { flex-direction: column; align-items: stretch; gap: 6px; }
.ring-node { min-width: 0; width: 100%; padding: 11px 12px; }
.rn-id { font-size: 12.5px; word-break: break-all; }
.ring-arrow {
align-self: center;
transform: rotate(90deg);
font-size: 15px;
line-height: 1;
}
/* 日志:详情另起一行完整显示,不再被省略号截掉 */
.log-row { row-gap: 3px; font-size: 11.5px; }
.lg-detail {
margin-left: 0;
width: 100%;
max-width: 100%;
text-align: left;
white-space: normal;
word-break: break-all;
}
.log-list { max-height: 260px; }
/* 任务/拓扑行:允许折行,归属另起一行 */
.task-row, .topo-row { flex-wrap: wrap; row-gap: 4px; font-size: 12.5px; }
.tp-owner { margin-left: 0; width: 100%; }
.title-actions { margin-left: 0; width: 100%; }
.title-actions > .w4f-btn { flex: 1 1 0; min-height: 36px; }
.section-title { flex-wrap: wrap; row-gap: 8px; }
/* 对话框表单:标签在上、输入在下 */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
.kpis { grid-template-columns: minmax(0, 1fr); }
.page-title { font-size: 15.5px; }
}
</style> </style>

View File

@ -167,11 +167,12 @@ onMounted(load)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* Let .content (App.vue) remain the single scroll container — see the note in
* StatusView: a page-level scroller sized by leftover flex space can collapse
* to zero height on narrow screens. */
.settings-page { .settings-page {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.page-top { .page-top {
@ -321,4 +322,21 @@ onMounted(load)
font-size: $font-size-sm; font-size: $font-size-sm;
color: $color-text-muted; color: $color-text-muted;
} }
/* ---------- narrow screens ----------
binary-row 把输入框与安装按钮横排,窄屏下路径输入框被挤到只剩几十像素;
setting-row 的 space-between 在窄屏也会把说明文字与开关挤在一起。 */
@media (max-width: 720px) {
.binary-row {
flex-direction: column;
align-items: stretch;
}
.binary-row .btn { min-height: 40px; justify-content: center; }
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style> </style>

View File

@ -496,11 +496,22 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* The pages inside .content must NOT be their own scrollers.
*
* App.vue already designates `.content` as the app's single scroll container
* (`flex:1; overflow-y:auto`). These pages were also `height:100%` +
* `overflow-y:auto`, producing a nested scroller whose height is the leftover
* space of a flex column. On a 375px phone the status page's fixed-height
* header (122px) plus the single-column KPI stack (565px) exceed the available
* 651px, so the inner `.status-body` was handed 0px while holding 1682px of
* content — the page looked completely frozen, since neither `.content` (which
* had nothing to scroll) nor the 0-height inner box could be swiped.
*
* Letting the page grow naturally and scroll in `.content` removes the whole
* class of bug: there is no leftover-space arithmetic left to get wrong. */
.status-page { .status-page {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
gap: 14px; gap: 14px;
} }
@ -527,12 +538,9 @@ onBeforeUnmount(() => {
} }
.status-body { .status-body {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
overflow-y: auto;
padding-right: 4px;
} }
.section { display: flex; flex-direction: column; } .section { display: flex; flex-direction: column; }
@ -638,4 +646,96 @@ onBeforeUnmount(() => {
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; .form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
} }
/* ---------- narrow screens ----------
窄屏下的主要问题:
- card-grid 的 minmax(320px) 在 375px 屏上会溢出(加上内边距超宽)
- topbar 的 space-between 把标题和按钮挤在一行
- fwd-head 一行塞下 7~8 个元素(类型/点/路由/协议/分组/归属/按钮)
- 分组标题的三个按钮 margin-left:auto 后被挤成细条
- 触控目标(small 按钮)低于 44px */
@media (max-width: 720px) {
.status-page { gap: 10px; }
/* 标题与工具栏换行,按钮占整行平分 */
.topbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px;
}
.tb-right { width: 100%; }
.tb-right > .w4f-btn { flex: 1 1 0; min-height: 40px; }
/* KPI 固定两列(auto-fit 在窄屏会变单列,四张卡拉得太长) */
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* 卡片网格改单列:消除 320px 最小宽导致的横向溢出 */
.card-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
/* 分组标题:标题一行,操作按钮另起一行平分 */
.section-title {
flex-wrap: wrap;
row-gap: 8px;
}
.grp-actions {
margin-left: 0;
width: 100%;
gap: 6px;
}
.grp-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; font-size: 12px; }
/* 转发卡:路由占整行,其余属性换行,按钮另起一行 */
.fwd-card { padding: 11px 12px; }
.fwd-head { row-gap: 7px; }
.fwd-route {
order: -1;
width: 100%;
margin-right: 0;
flex-wrap: wrap;
row-gap: 2px;
font-size: 13px;
}
.fwd-owner { max-width: 100%; }
.fwd-actions {
width: 100%;
margin-top: 2px;
}
.fwd-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
/* 远程节点卡:名称占行,操作按钮平分 */
.node-card { padding: 14px 15px; }
.nc-name { width: 100%; margin-right: 0; }
.nc-actions { width: 100%; }
.nc-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
.nc-cluster-note { max-width: 100%; }
.meta-pill { max-width: 100%; }
.np-err { max-width: 45%; }
/* 对话框表单:标签在上、输入在下(窄屏下 72px 标签挤死输入框) */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
/* Keep the KPI grid at TWO columns even on the narrowest phones.
* Dropping to one column made four full-width cards ~565px tall, which on its
* own exceeded the whole viewport and (with the page-level scroller of the
* day) collapsed the content area to zero height. Two columns keeps each card
* ~160px wide — enough for the 27px number + 11.5px caption, which is all a
* KPI card holds. */
.kpis { gap: 8px; }
.fwd-route { font-size: 12.5px; }
.page-title { font-size: 16px; }
}
</style> </style>

View File

@ -362,4 +362,37 @@ const onDeleteKey = async (k: ApiKey) => {
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
} }
/* ---------- narrow screens ----------
两张 el-table 列实宽加起来 ~900px,窄屏必然需要横向滚动;
Element Plus 自己会处理表内滚动,但卡片内边距与标题行需要压缩,
否则“导出 CSV / 新建”两个按钮会把标题挤出容器。 */
@media (max-width: 720px) {
.users-view { gap: 14px; }
.card { padding: 13px 13px; border-radius: 14px; }
.card-h {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
.card-h .h-actions {
display: flex;
gap: 8px;
}
/* Element 按钮在窄屏平分整行,并抬到 40px 触控高度 */
.card-h .h-actions :deep(.el-button) {
flex: 1 1 0;
min-height: 40px;
margin-left: 0;
}
/* 表格横向滚动时给个视觉提示,并保证不把卡片撑宽 */
:deep(.el-table) {
font-size: 12.5px;
}
:deep(.el-table__body-wrapper) {
-webkit-overflow-scrolling: touch;
}
}
</style> </style>