mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +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)
221 lines
8.9 KiB
Go
221 lines
8.9 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"webui4frpc/internal/store"
|
|
)
|
|
|
|
// TestSelfRemoveViaToken: a node-removal command reaches the target node
|
|
// through OnToken; it stops owned workers, re-queues its forwards, drops its
|
|
// ring position, and the token flows to the original successor.
|
|
func TestSelfRemoveViaToken(t *testing.T) {
|
|
eng := newTestEngine("n1", true)
|
|
eng.state.InsertAfter("n1", Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
|
|
|
|
// n1 owns a forward (via 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")
|
|
|
|
// n2 publishes remove-node for itself
|
|
rm := eng.state.AddRemoveNode("n2")
|
|
if rm.RemoveNode != "n2" {
|
|
t.Fatalf("remove node cmd = %+v", rm)
|
|
}
|
|
|
|
// run OnToken at n2 (lowest-load, so it claims the remove command).
|
|
// State order is [n2, n1] so LowestAlive picks n2.
|
|
eng2 := newTestEngine("n2", false)
|
|
eng2.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
|
|
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 90, NetPct: 90}})
|
|
eng2.state.Topology = map[string]*TopoEntry{
|
|
p.ID: {TaskID: p.ID, OwnerID: "n2", Active: true, Local: p.Local, Remote: p.Remote, Link: p.Link},
|
|
}
|
|
eng2.state.PendingTasks = map[string]*Task{rm.ID: rm}
|
|
out, err := eng2.OnToken(context.Background(), &Token{Cycle: 1, State: eng2.state})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out == nil {
|
|
t.Fatal("nil token after OnToken")
|
|
}
|
|
// n2 removed itself from ring (NOT re-added by the UpsertNode step)
|
|
if eng2.state.Find("n2") >= 0 {
|
|
t.Fatalf("n2 still in ring: %+v", eng2.state.Nodes)
|
|
}
|
|
// the forward n2 owned was re-queued as pending for another member
|
|
if len(eng2.state.PendingList()) == 0 {
|
|
t.Fatalf("no pending re-queue after self-remove: %+v", eng2.state.PendingList())
|
|
}
|
|
// removedNext captured so Forward can hand the token to the old successor
|
|
if eng2.removedNext != "n1" {
|
|
t.Fatalf("removedNext = %q want n1", eng2.removedNext)
|
|
}
|
|
}
|
|
|
|
// TestRemoveCommandRidesPastLowest: a node-removal command directed at a
|
|
// NON-lowest node must NOT be claimed by the lowest-load node (the old bug
|
|
// corrupted it into a phantom forward via Handler.Claim). It stays pending
|
|
// and rides the token until it reaches the target, which then self-removes.
|
|
func TestRemoveCommandRidesPastLowest(t *testing.T) {
|
|
// 3-node ring order [n2, n1, n3]: n2 is lowest, n3 is the remove target.
|
|
eng2 := newTestEngine("n2", false)
|
|
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
|
|
eng2.state.UpsertNode(Node{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90}})
|
|
rm := eng2.state.AddRemoveNode("n3")
|
|
|
|
// Token reaches n2 (lowest) carrying the remove command for n3.
|
|
if _, err := eng2.OnToken(context.Background(), &Token{Cycle: 1, State: eng2.state}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// n2 must NOT have consumed the remove — it must still be pending, riding.
|
|
if _, ok := eng2.state.PendingTasks[rm.ID]; !ok {
|
|
t.Fatal("lowest node n2 consumed a remove command aimed at n3 (should ride to target)")
|
|
}
|
|
if i := eng2.state.Find("n3"); i < 0 {
|
|
t.Fatal("n3 was removed by non-target n2 (should still be in ring)")
|
|
}
|
|
|
|
// Token now reaches the TARGET (n3): it self-removes + captures successor.
|
|
eng3 := newTestEngine("n3", false)
|
|
eng3.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
|
|
eng3.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
|
|
eng3.state.PendingTasks = map[string]*Task{rm.ID: rm}
|
|
if _, err := eng3.OnToken(context.Background(), &Token{Cycle: 2, State: eng3.state}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eng3.state.Find("n3") >= 0 {
|
|
t.Fatalf("n3 did not self-remove: %+v", eng3.state.Nodes)
|
|
}
|
|
// Ring order [n3, n1, n2] → n3's original successor is n1.
|
|
if eng3.removedNext != "n1" {
|
|
t.Fatalf("removedNext = %q want n1", eng3.removedNext)
|
|
}
|
|
}
|
|
|
|
// TestRemoveCommandFulfilledNoPhantom: when a remove command's target has
|
|
// already left the ring (or was never a member), the lowest-load node that
|
|
// re-encounters it must DROP it — NOT fall through to Handler.Claim and spawn
|
|
// a phantom empty forward (the remove task carries no local/remote/link).
|
|
func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
|
|
var claimed []*Task
|
|
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, "")
|
|
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)
|
|
}
|
|
if _, ok := eng.state.PendingTasks[rm.ID]; ok {
|
|
t.Fatal("fulfilled remove command was not dropped from pending")
|
|
}
|
|
if len(claimed) != 0 {
|
|
t.Fatalf("Handler.Claim called for a remove command: %+v", claimed)
|
|
}
|
|
if len(eng.state.TopologyList()) != 0 {
|
|
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)
|
|
}
|
|
}
|