Files
webui4frpc/.zcode/plans/plan-sess_cf2ae9b7-2221-409b-b804-66d894f1b069.md
jianf eda9bb9597 feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs
- 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)
2026-08-19 21:09:24 +08:00

10 KiB
Raw Blame History

集群修复 + 加入密钥 + 画布/状态页增强 + Seq 删除

P0关键 Bug集群已炸

A1. Leader 退出后无 failover — ring_engine.go runCommands 自退出分支 (L358-383)

根因leader 自退出时 SelfRemoveLeaderID="" → token 携空 LeaderID → 后继 IsLeader()=false → 调 ForwardSend → cycle 永不推进;WatchLeaderAlivePredecessor("") 失败 → 无人晋升 → 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 → 调 Sendbump cycle→ ring 恢复正常。

A2. 退出后残留旧集群信息 — ring_engine.go Forward (L439-461) + 新 detachAsStandalone()

根因SelfRemove 只移除自己出 Nodes保留 Topology/Pending/Log/其他成员。退出后不再收 token → e.state 冻结 → Snapshot() 返回旧画面。非前端 bug。

修复

  1. 新增 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)
}
  1. 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() 返回 standalone1 节点=自己0 转发,空日志)→ 前端顶部数据区显示"待加入"+ 1 成员 + 0 活跃转发 → 不再残留旧拓扑/转发/日志。

A3. 测试

  • 现有 ring_remove_test.go 3 个测试全是非 leader 退出wasLeader=false 不指定后继detach 在 Forward 中测试不调)→ 不受影响 ✓
  • 新增 TestLeaderSelfRemoveDesignatesSuccessorleader 自退出 → 后继被指定为新 leader
  • 新增 TestDetachAfterForward:自退出 + Forward 后 state 重置为 standalone

P1State.Seq 删除 — ring.go + ring_engine.go

plan.md 从未要求 SeqNextTaskID 注释 L213-221 已自述回归 bug

  • ring.go L90State 结构体删 Seq int64 json:"seq"
  • ring.go L212-235 NextTaskIDmax := int64(0)(删 max := s.Seq),删 s.Seq = max+1return fmt.Sprintf("t%d", max+1)
  • ring_engine.go L635CreateCluster 删 Seq: e.state.Seq
  • LogEntry.Seqring_log.go保留日志水印plan §增量日志同步)

P1集群加入密钥per-node 准入密钥)

设计

  • 每个节点有 nodeKey(随机 16 字节 hex首次启动生成、持久化到 DB、重启后读回稳定
  • 画布 webui 显示本节点 nodeKey可复制加入集群对话框需填对端地址 + 对端 nodeKey
  • 对端 handleClusterJoin 验证 ji.JoinKey == e.nodeKey,不匹配 403
  • CreateCluster/AdoptState 不重新生成init 时已存在)

B1. 数据模型

  • store.Settingsstore.go L114-119NodeKey string json:"nodeKey,omitempty" + migrate 列 node_key TEXT NOT NULL DEFAULT ''
  • store.SetNodeKey(key string) errorUPDATE settings SET node_key=?
  • Engine structring_engine.go L24-80nodeKey string + func (e *Engine) NodeKey() string { return e.nodeKey }
  • JoinInforing_engine.go L544-550JoinKey string json:"joinKey,omitempty"
  • RingSnapshotring_engine.go L464-):加 NodeKey string json:"nodeKey"

B2. 初始化 + 持久化

  • NewEnginering_engine.go L84-105签名加 nodeKey string 参数
  • main.go 引擎初始化L121settings.NodeKey;若空 → crypto/rand 生成 16 字节 hex → store.SetNodeKey 持久化 → 传给 NewEngine

B3. 加入验证

  • handleClusterJoinhandlers.go L530-556decode JoinInfo 后加 if ji.JoinKey != h.Ring.NodeKey() { 403 }
  • JoinRingAddrtoken_join.go L54-57签名加 joinKey string,设 ji.JoinKey = joinKey
  • JoinRingtoken_join.go L16-47JoinInfo 已含 JoinKey随 POST body 发送

B4. -peer 启动引导

  • main.go flags-join-key string(或 W4F_JOIN_KEY env
  • main.go L336-346 bootstrapring.JoinRing(jc, peers[0], ji)JoinRingAddr(ctx, peers[0], *joinKey)

B5. 前端

  • types.tsRingSnapshotnodeKey?: string
  • api.ts clusterJoinRingL137-142(addr, joinKey) → body {addr, joinKey}
  • ClusterView.vue
    • 顶部 hero 或独立区域:显示本机 nodeKeyring.nodeKey+ 复制按钮
    • 加入对话框L167-178加"加入密钥"输入框(joinDialog.key
    • onJoinL369-383api.clusterJoinRing(addr, key)
    • joinDialog reactiveL193key: ''

P2画布边启用/禁用开关 + 分组标签 — PortEdge.vue + CanvasView.vue

PortEdge.vue

  • computed isDisabled(读 props.data?.disabled)、groupName(读 props.data?.group
  • emits 加 toggle-disablededit-grouppayload {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不误开端口编辑器
  • strokeColordisabled 时灰色(var(--el-color-info-light-5)

CanvasView.vue

  • L60-67 edge 模板:加 @toggle-disabled="onToggleDisabled" @edit-group="onEditGroup"
  • import ElMessageBox
  • onToggleDisabled({edgeId}):翻 edge.data.disableddirty=true
  • onEditGroup({edgeId})ElMessageBox.prompt列现有分组名留空=未分组)→ 设 edge.data.groupdirty=true
  • buildPayload L638 已序列化 disabledL637 已序列化 group保存重建 L690-691 已回填 → 无需改)

P2状态页分组管理 — store + handler + route + api + StatusView

后端

  • store.goSetLinkGroup(local, remote string, port int, group string) errorUPDATE links SET grp=? WHERE ...;列 grp 已存在)
  • handlers_forwards.goassignReq{Local,Remote,RemotePort,Group} + handleForwardsAssignfindLinkByTriple→SetLinkGroup+ handleForwardsGroupDeleteListLinks 过滤 group→逐条 SetLinkGroup(...,"")
  • server.go L78-81 路由块:/forwards/assign/forwards/group/delete(均 write 级)

前端

  • api.tsassignGroup(local,remote,remotePort,group) + deleteGroup(group)
  • StatusView.vue
    • 卡片 .fwd-head L70 后:<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→loadStatus
    • deleteGroup(group)confirm→api.deleteGroup→loadStatus
    • import ElMessageBox

不变(保留)

  • 状态页所有现有按钮(启动/停止转发、一键启动/停止整组)+ forwards 端点
  • handlers_forwards.go 全部现有函数
  • ring 语义SubmitTask/RevokeTask/HasTask/leader 选举/拓扑)
  • 画布端口编辑对话框已有"分组"字段(保留,边上标签是额外快速入口)

构建验证

  1. go test ./internal/cluster/...Seq 删除 + leader failover + detach 不破坏现有测试 + 新测试通过)
  2. cd web && npm run buildcp -r web/dist internal/httpapi/distgo build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc
  3. docker rm -f w4f-node-a b c d e; bash deploy/run-cluster.sh
  4. leader failovera 为 leader → a 退出 → b 成为新 leadercycle 继续推进)→ ring 正常
  5. 退出残留a 退出后 a 的集群页显示 standalone1 节点、0 转发、空日志),不残留旧拓扑
  6. 加入密钥:创建集群 → 显示 nodeKey → b 加入需填 a 的 nodeKey → 错误密钥 403 → 正确密钥加入成功
  7. 画布边开关:点"禁"→边变灰→保存→状态页 stopped点"通"→恢复
  8. 画布边分组:点分组标签→输入名→保存→状态页显示分组
  9. 状态页分组管理:卡片分组标签→改组→即时生效;分组头删除分组→全移至未分组
  10. bash deploy/test-forward.sh 通过

影响文件

  • Gointernal/cluster/ring.goring_engine.goring_leader.go(仅注释/无改)、token_join.gointernal/store/store.gointernal/httpapi/handlers.gohandlers_forwards.goserver.gocmd/webui4frpc/main.go
  • 前端:web/src/components/PortEdge.vueviews/CanvasView.vueviews/ClusterView.vueviews/StatusView.vueapi.tstypes.ts