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)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -104,7 +104,7 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
eng := NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
claim: func(ctx context.Context, tk *Task) error { claimed = append(claimed, tk); return nil }},
sendNull, "n1:7500", true)
sendNull, "n1:7500", true, "")
rm := eng.state.AddRemoveNode("node-x:7500") // target not in the ring
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
@ -119,3 +119,102 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
t.Fatalf("phantom topology entry created: %+v", eng.state.TopologyList())
}
}
// TestLeaderSelfRemoveDesignatesSuccessor: when the LEADER self-removes, it
// must designate its successor as the new leader before publishing the token.
// Without this, LeaderID would be "" (SelfRemove clears it), no node would
// call Send (cycle never advances), and WatchLeader can't find
// AlivePredecessor("") to promote anyone — the ring runs leaderless and dies.
func TestLeaderSelfRemoveDesignatesSuccessor(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.InsertAfter("n1", Node{
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
})
// n1 (leader) publishes a remove-node command for itself.
rm := eng.state.AddRemoveNode("n1")
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
// n1 removed itself from the ring.
if eng.state.Find("n1") >= 0 {
t.Fatalf("n1 still in ring: %+v", eng.state.Nodes)
}
// n2 designated as the new leader (not "" — the old bug).
if eng.state.LeaderID != "n2" {
t.Fatalf("LeaderID = %q want n2 (successor should be designated as leader)", eng.state.LeaderID)
}
// n2 marked IsLeader in Nodes.
if i := eng.state.Find("n2"); i >= 0 && !eng.state.Nodes[i].IsLeader {
t.Fatalf("n2 not marked IsLeader: %+v", eng.state.Nodes[i])
}
// removedNext captured so Forward can hand the token to the old successor.
if eng.removedNext != "n2" {
t.Fatalf("removedNext = %q want n2", eng.removedNext)
}
}
// TestDetachAfterForward: after self-leave + Forward (token handed to the old
// successor), the engine resets to a fresh standalone state so Snapshot() no
// longer serves the old cluster picture (members, topology, pending, log).
func TestDetachAfterForward(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.InsertAfter("n1", Node{
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
})
// Give n1 an owned forward so the old state has a non-empty topology.
p := eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
eng.state.ClaimPending(p.ID)
eng.state.AddTopology(p, "n1")
rm := eng.state.AddRemoveNode("n1")
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
// Before Forward: self-removed, removedNext captured, old state retained
// (n2 still in Nodes, n1's forward re-queued as pending by SelfRemove).
if !eng.selfRemoved {
t.Fatal("selfRemoved not set before Forward")
}
if eng.removedNext != "n2" {
t.Fatalf("removedNext = %q want n2", eng.removedNext)
}
if eng.state.Find("n2") < 0 {
t.Fatal("n2 (old member) missing before Forward — test setup wrong")
}
// Forward hands off the token (sendNull no-op) then detaches to standalone.
if err := eng.Forward(context.Background(), out); err != nil {
t.Fatal(err)
}
// After Forward: fresh standalone state.
if eng.state.LeaderID != "n1" {
t.Fatalf("LeaderID = %q want n1 (standalone leader after detach)", eng.state.LeaderID)
}
if len(eng.state.Nodes) != 1 || eng.state.Nodes[0].ID != "n1" {
t.Fatalf("state not standalone (want just n1): %+v", eng.state.Nodes)
}
if len(eng.state.Topology) != 0 {
t.Fatalf("topology not cleared after detach: %+v", eng.state.Topology)
}
if len(eng.state.PendingTasks) != 0 {
t.Fatalf("pending not cleared after detach: %+v", eng.state.PendingList())
}
if eng.selfRemoved {
t.Fatal("selfRemoved should be cleared after detach")
}
if eng.removedNext != "" {
t.Fatalf("removedNext should be empty after detach, got %q", eng.removedNext)
}
}