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

172 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 集群修复 + 加入密钥 + 画布/状态页增强 + 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
```go
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。
**修复**
1. 新增 `detachAsStandalone()`(≈ CreateCluster 去掉 IsMember 守卫 + 重置 Log
```go
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)
}
```
2.`Forward` 中,将 token 交给 removedNext 后(已发送),若 `e.selfRemoved` 则 detach
```go
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 中测试不调)→ 不受影响 ✓
- 新增 `TestLeaderSelfRemoveDesignatesSuccessor`leader 自退出 → 后继被指定为新 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 NextTaskID`max := int64(0)`(删 `max := s.Seq`),删 `s.Seq = max+1``return 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.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=?`
- `Engine` structring_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-556decode JoinInfo 后加 `if ji.JoinKey != h.Ring.NodeKey() { 403 }`
- `JoinRingAddr`token_join.go L54-57签名加 `joinKey string`,设 `ji.JoinKey = joinKey`
- `JoinRing`token_join.go L16-47JoinInfo 已含 JoinKey随 POST body 发送
### B4. -peer 启动引导
- main.go flags`-join-key string`(或 `W4F_JOIN_KEY` env
- 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)`
- `joinDialog` reactiveL193`key: ''`
## 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不误开端口编辑器
- strokeColordisabled 时灰色(`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=true`
- `onEditGroup({edgeId})`ElMessageBox.prompt列现有分组名留空=未分组)→ 设 `edge.data.group``dirty=true`
- buildPayload L638 已序列化 disabledL637 已序列化 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-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 build``cp -r web/dist internal/httpapi/dist``go 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 failover**a 为 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` 通过
## 影响文件
- 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`