mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
10 KiB
10 KiB
集群修复 + 加入密钥 + 画布/状态页增强 + Seq 删除
P0:关键 Bug(集群已炸)
A1. Leader 退出后无 failover — ring_engine.go runCommands 自退出分支 (L358-383)
根因:leader 自退出时 SelfRemove 清 LeaderID="" → token 携空 LeaderID → 后继 IsLeader()=false → 调 Forward 非 Send → cycle 永不推进;WatchLeader 的 AlivePredecessor("") 失败 → 无人晋升 → ring 无 leader 直至 token 丢失。
修复:在 SelfRemove 前捕获 wasLeader,之后若 wasLeader 则指定 removedNext(原后继)为新 leader:
wasLeader := e.state.LeaderID == e.ID
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
e.removedNext = succ // 已有逻辑(L372-374)
}
e.state.SelfRemove(e.ID) // 已有
e.selfRemoved = true // 已有
// 新增:leader 退出时指定后继为新 leader
if wasLeader && e.removedNext != "" {
e.state.LeaderID = e.removedNext
for i := range e.state.Nodes {
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.removedNext
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.removedNext})
}
}
后继收到 token 时 IsLeader()=true → 调 Send(bump cycle)→ ring 恢复正常。
A2. 退出后残留旧集群信息 — ring_engine.go Forward (L439-461) + 新 detachAsStandalone()
根因:SelfRemove 只移除自己出 Nodes,保留 Topology/Pending/Log/其他成员。退出后不再收 token → e.state 冻结 → Snapshot() 返回旧画面。非前端 bug。
修复:
- 新增
detachAsStandalone()(≈ CreateCluster 去掉 IsMember 守卫 + 重置 Log):
func (e *Engine) detachAsStandalone() {
e.state = State{
LeaderID: e.ID, PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{}, RoundDelay: 2 * time.Second,
}
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
e.selfRemoved = false; e.removedNext = ""
e.lastRingStart = time.Time{}; e.lastTokenAt = 0; e.lastSyncAt = 0
e.inflight.clear(); e.failCount = map[string]int{}; e.published = map[string]struct{}{}
if e.Log != nil {
e.Log = NewClusterLog() // 清空旧集群日志
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
log.Printf("ring[%s] detached to standalone after self-leave", e.ID)
}
- 在
Forward中,将 token 交给 removedNext 后(已发送),若e.selfRemoved则 detach:
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
if e.removedNext != "" {
next := e.removedNext; e.removedNext = ""
var err error
if e.send != nil {
err = e.send(ctx, next, tk) // 先发 token(携带旧 state 含新 leader)
}
if e.selfRemoved { // 发完后 detach(不影响已发 token)
e.detachAsStandalone()
}
return err
}
// ... 原有逻辑
}
效果:退出后 Snapshot() 返回 standalone(1 节点=自己,0 转发,空日志)→ 前端顶部数据区显示"待加入"+ 1 成员 + 0 活跃转发 → 不再残留旧拓扑/转发/日志。
A3. 测试
- 现有
ring_remove_test.go3 个测试全是非 leader 退出(wasLeader=false 不指定后继,detach 在 Forward 中测试不调)→ 不受影响 ✓ - 新增
TestLeaderSelfRemoveDesignatesSuccessor:leader 自退出 → 后继被指定为新 leader - 新增
TestDetachAfterForward:自退出 + Forward 后 state 重置为 standalone
P1:State.Seq 删除 — ring.go + ring_engine.go
(plan.md 从未要求 Seq;NextTaskID 注释 L213-221 已自述回归 bug)
- ring.go L90:State 结构体删
Seq int64 json:"seq" - ring.go L212-235 NextTaskID:
max := int64(0)(删max := s.Seq),删s.Seq = max+1,return fmt.Sprintf("t%d", max+1) - ring_engine.go L635:CreateCluster 删
Seq: e.state.Seq - LogEntry.Seq(ring_log.go)保留(日志水印,plan §增量日志同步)
P1:集群加入密钥(per-node 准入密钥)
设计
- 每个节点有
nodeKey(随机 16 字节 hex),首次启动生成、持久化到 DB、重启后读回(稳定) - 画布 webui 显示本节点 nodeKey(可复制);加入集群对话框需填对端地址 + 对端 nodeKey
- 对端
handleClusterJoin验证ji.JoinKey == e.nodeKey,不匹配 403 CreateCluster/AdoptState不重新生成(init 时已存在)
B1. 数据模型
store.Settings(store.go L114-119):加NodeKey string json:"nodeKey,omitempty"+ migrate 列node_key TEXT NOT NULL DEFAULT ''store.SetNodeKey(key string) error:UPDATE settings SET node_key=?Enginestruct(ring_engine.go L24-80):加nodeKey string+func (e *Engine) NodeKey() string { return e.nodeKey }JoinInfo(ring_engine.go L544-550):加JoinKey string json:"joinKey,omitempty"RingSnapshot(ring_engine.go L464-):加NodeKey string json:"nodeKey"
B2. 初始化 + 持久化
NewEngine(ring_engine.go L84-105):签名加nodeKey string参数- main.go 引擎初始化(L121):读
settings.NodeKey;若空 →crypto/rand生成 16 字节 hex →store.SetNodeKey持久化 → 传给 NewEngine
B3. 加入验证
handleClusterJoin(handlers.go L530-556):decode JoinInfo 后加if ji.JoinKey != h.Ring.NodeKey() { 403 }JoinRingAddr(token_join.go L54-57):签名加joinKey string,设ji.JoinKey = joinKeyJoinRing(token_join.go L16-47):JoinInfo 已含 JoinKey,随 POST body 发送
B4. -peer 启动引导
- main.go flags:加
-join-key string(或W4F_JOIN_KEYenv) - main.go L336-346 bootstrap:
ring.JoinRing(jc, peers[0], ji)→JoinRingAddr(ctx, peers[0], *joinKey)
B5. 前端
- types.ts:
RingSnapshot加nodeKey?: string - api.ts
clusterJoinRing(L137-142):改(addr, joinKey)→ body{addr, joinKey} - ClusterView.vue:
- 顶部 hero 或独立区域:显示本机 nodeKey(
ring.nodeKey)+ 复制按钮 - 加入对话框(L167-178):加"加入密钥"输入框(
joinDialog.key) onJoin(L369-383):api.clusterJoinRing(addr, key)joinDialogreactive(L193):加key: ''
- 顶部 hero 或独立区域:显示本机 nodeKey(
P2:画布边启用/禁用开关 + 分组标签 — PortEdge.vue + CanvasView.vue
PortEdge.vue
- computed
isDisabled(读props.data?.disabled)、groupName(读props.data?.group) - emits 加
toggle-disabled、edit-group(payload{edgeId}) - 模板
.port-label内端口号旁:<span class="port-toggle" :class="{off:isDisabled}" @pointerdown.stop @click.stop="emit('toggle-disabled',{edgeId:props.id})">{{ isDisabled?'禁':'通' }}</span>;<span v-if="groupName" class="port-group" @pointerdown.stop @click.stop="emit('edit-group',{edgeId:props.id})">{{ groupName }}</span> @pointerdown.stop阻止拖拽(label 的 onPointerDown 不触发 → dragging 保持 false → onPointerUp!wasDrag直接 return,不误开端口编辑器)- strokeColor:disabled 时灰色(
var(--el-color-info-light-5))
CanvasView.vue
- L60-67 edge 模板:加
@toggle-disabled="onToggleDisabled" @edit-group="onEditGroup" - import ElMessageBox
onToggleDisabled({edgeId}):翻edge.data.disabled,dirty=trueonEditGroup({edgeId}):ElMessageBox.prompt(列现有分组名,留空=未分组)→ 设edge.data.group,dirty=true- (buildPayload L638 已序列化 disabled,L637 已序列化 group,保存重建 L690-691 已回填 → 无需改)
P2:状态页分组管理 — store + handler + route + api + StatusView
后端
- store.go:
SetLinkGroup(local, remote string, port int, group string) error(UPDATE links SET grp=? WHERE ...;列 grp 已存在) - handlers_forwards.go:
assignReq{Local,Remote,RemotePort,Group}+handleForwardsAssign(findLinkByTriple→SetLinkGroup)+handleForwardsGroupDelete(ListLinks 过滤 group→逐条 SetLinkGroup(...,"")) - server.go L78-81 路由块:
/forwards/assign、/forwards/group/delete(均 write 级)
前端
- api.ts:
assignGroup(local,remote,remotePort,group)+deleteGroup(group) - StatusView.vue:
- 卡片
.fwd-headL70 后:<span class="w4f-tag w4f-tag-info group-chip" @click="editGroup(f)">{{ f.group || '未分组' }}</span> - 分组头 L45-48
.grp-actions:<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button> editGroup(f):ElMessageBox.prompt(列现有分组,留空=移出)→ api.assignGroup→loadStatusdeleteGroup(group):confirm→api.deleteGroup→loadStatus- import ElMessageBox
- 卡片
不变(保留)
- 状态页所有现有按钮(启动/停止转发、一键启动/停止整组)+ forwards 端点
- handlers_forwards.go 全部现有函数
- ring 语义(SubmitTask/RevokeTask/HasTask/leader 选举/拓扑)
- 画布端口编辑对话框已有"分组"字段(保留,边上标签是额外快速入口)
构建验证
go test ./internal/cluster/...(Seq 删除 + leader failover + detach 不破坏现有测试 + 新测试通过)cd web && npm run build;cp -r web/dist internal/httpapi/dist;go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpcdocker rm -f w4f-node-a b c d e; bash deploy/run-cluster.sh- leader failover:a 为 leader → a 退出 → b 成为新 leader(cycle 继续推进)→ ring 正常
- 退出残留:a 退出后 a 的集群页显示 standalone(1 节点、0 转发、空日志),不残留旧拓扑
- 加入密钥:创建集群 → 显示 nodeKey → b 加入需填 a 的 nodeKey → 错误密钥 403 → 正确密钥加入成功
- 画布边开关:点"禁"→边变灰→保存→状态页 stopped;点"通"→恢复
- 画布边分组:点分组标签→输入名→保存→状态页显示分组
- 状态页分组管理:卡片分组标签→改组→即时生效;分组头删除分组→全移至未分组
bash deploy/test-forward.sh通过
影响文件
- Go:
internal/cluster/ring.go、ring_engine.go、ring_leader.go(仅注释/无改)、token_join.go、internal/store/store.go、internal/httpapi/handlers.go、handlers_forwards.go、server.go、cmd/webui4frpc/main.go - 前端:
web/src/components/PortEdge.vue、views/CanvasView.vue、views/ClusterView.vue、views/StatusView.vue、api.ts、types.ts