mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user