Skip to content

Commit ab14ba5

Browse files
committed
Registry hardening: panic recovery, WAL replay/cap, 3-phase patterns
Panic recovery wrapper around registry handlers — a panicking handler no longer takes the server down; stack trace logged, connection closed. WAL replay on startup. Size cap (configurable) so a stuck flushSave cannot let the WAL grow until disk fills. 3-phase pattern (RLock-verify-unlock-Lock-mutate) applied to handleRotateKey + handleSetKeyExpiry — eliminates lock-hold during signature checks. Plus: heartbeat-ack pre-build to keep the hot path off json.Marshal, list_nodes cache-invalidation hooks at every membership mutation site, apply_snapshot deep-copy moved outside s.mu (~350x lock-hold drop), snapshot lock-discipline tests, stale-threshold tests. RelayOnly registration mode: registry substitutes beacon_addr for real_addr in resolve responses when the peer registered with RelayOnly=true.
1 parent fc7d034 commit ab14ba5

17 files changed

Lines changed: 2787 additions & 81 deletions
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package registry
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"sync"
9+
"sync/atomic"
10+
"testing"
11+
"time"
12+
)
13+
14+
// buildSnapshot constructs a synthetic snapshot JSON blob for tests. It
15+
// produces the exact wire shape applySnapshot expects.
16+
func buildSnapshot(t *testing.T, nodes int, networks int) []byte {
17+
t.Helper()
18+
now := time.Now().UTC().Format(time.RFC3339)
19+
20+
snap := snapshot{
21+
NextNode: uint32(nodes + 100),
22+
NextNet: uint16(networks + 10),
23+
Nodes: make(map[string]*snapshotNode, nodes),
24+
Networks: make(map[string]*snapshotNet, networks),
25+
}
26+
27+
for i := 1; i <= nodes; i++ {
28+
key := fmt.Sprintf("%d", i)
29+
snap.Nodes[key] = &snapshotNode{
30+
ID: uint32(i),
31+
Owner: fmt.Sprintf("owner-%d", i),
32+
PublicKey: "AAAA" + fmt.Sprintf("%032d", i),
33+
RealAddr: fmt.Sprintf("10.0.0.%d:9000", i%255),
34+
Networks: []uint16{1},
35+
LastSeen: now,
36+
Hostname: fmt.Sprintf("host-%d", i),
37+
}
38+
}
39+
40+
for i := 1; i <= networks; i++ {
41+
key := fmt.Sprintf("%d", i)
42+
members := make([]uint32, 0, 5)
43+
for j := i; j <= nodes && j < i+5; j++ {
44+
members = append(members, uint32(j))
45+
}
46+
snap.Networks[key] = &snapshotNet{
47+
ID: uint16(i),
48+
Name: fmt.Sprintf("net-%d", i),
49+
JoinRule: "open",
50+
Members: members,
51+
Created: now,
52+
}
53+
}
54+
55+
data, err := json.Marshal(snap)
56+
if err != nil {
57+
t.Fatalf("marshal: %v", err)
58+
}
59+
return data
60+
}
61+
62+
// TestApplySnapshotBasic locks down the happy path: applySnapshot replaces
63+
// the registry's nodes, networks, and indices wholesale.
64+
func TestApplySnapshotBasic(t *testing.T) {
65+
s := newTestServer(t, "")
66+
data := buildSnapshot(t, 5, 2)
67+
68+
if err := s.applySnapshot(data); err != nil {
69+
t.Fatalf("applySnapshot: %v", err)
70+
}
71+
72+
s.mu.RLock()
73+
defer s.mu.RUnlock()
74+
if got := len(s.nodes); got != 5 {
75+
t.Fatalf("nodes count = %d, want 5", got)
76+
}
77+
if got := len(s.networks); got != 2 {
78+
t.Fatalf("networks count = %d, want 2", got)
79+
}
80+
if s.nextNode != 105 {
81+
t.Fatalf("nextNode = %d, want 105", s.nextNode)
82+
}
83+
if s.nextNet != 12 {
84+
t.Fatalf("nextNet = %d, want 12", s.nextNet)
85+
}
86+
// Hostname index populated
87+
if got := s.hostnameIdx["host-1"]; got != 1 {
88+
t.Fatalf("hostnameIdx[host-1] = %d, want 1", got)
89+
}
90+
}
91+
92+
// TestApplySnapshotReplacesPreviousState validates that a second apply
93+
// fully replaces the prior state — the legacy contract.
94+
func TestApplySnapshotReplacesPreviousState(t *testing.T) {
95+
s := newTestServer(t, "")
96+
if err := s.applySnapshot(buildSnapshot(t, 3, 1)); err != nil {
97+
t.Fatalf("first apply: %v", err)
98+
}
99+
if err := s.applySnapshot(buildSnapshot(t, 7, 3)); err != nil {
100+
t.Fatalf("second apply: %v", err)
101+
}
102+
s.mu.RLock()
103+
defer s.mu.RUnlock()
104+
if got := len(s.nodes); got != 7 {
105+
t.Fatalf("nodes count after second apply = %d, want 7", got)
106+
}
107+
if got := len(s.networks); got != 3 {
108+
t.Fatalf("networks count after second apply = %d, want 3", got)
109+
}
110+
}
111+
112+
// TestApplySnapshotRejectsTooLarge enforces the size cap.
113+
func TestApplySnapshotRejectsTooLarge(t *testing.T) {
114+
s := newTestServer(t, "")
115+
huge := make([]byte, maxSnapshotSize+1)
116+
if err := s.applySnapshot(huge); err == nil {
117+
t.Fatalf("expected error for oversized snapshot")
118+
}
119+
}
120+
121+
// TestApplySnapshotConcurrentReadsStaySane stresses the new pattern: while
122+
// applySnapshot runs, concurrent readers (sampleStats) must see either the
123+
// fully-old or fully-new state — not a partial mix. Run with -race.
124+
func TestApplySnapshotConcurrentReadsStaySane(t *testing.T) {
125+
s := newTestServer(t, "")
126+
if err := s.applySnapshot(buildSnapshot(t, 50, 5)); err != nil {
127+
t.Fatalf("seed: %v", err)
128+
}
129+
130+
const iterations = 50
131+
var stop atomic.Bool
132+
var wg sync.WaitGroup
133+
134+
wg.Add(1)
135+
go func() {
136+
defer wg.Done()
137+
for i := 0; i < iterations && !stop.Load(); i++ {
138+
result := s.sampleStats()
139+
// State count is either the prior state size or new state size,
140+
// not negative or absurd. We don't pin a specific count because
141+
// it's racing with applySnapshot; we just want the read path to
142+
// not panic and to return a non-empty global sample.
143+
if result.global.TotalNodes < 0 {
144+
t.Errorf("negative TotalNodes")
145+
return
146+
}
147+
}
148+
}()
149+
150+
wg.Add(1)
151+
go func() {
152+
defer wg.Done()
153+
for i := 0; i < iterations/5 && !stop.Load(); i++ {
154+
data := buildSnapshot(t, 50+i, 5)
155+
if err := s.applySnapshot(data); err != nil {
156+
t.Errorf("applySnapshot at iter %d: %v", i, err)
157+
return
158+
}
159+
}
160+
}()
161+
162+
wg.Wait()
163+
stop.Store(true)
164+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package registry
4+
5+
import (
6+
"sync"
7+
"testing"
8+
"time"
9+
)
10+
11+
// stubCacheEntry installs a sentinel cache entry under the given netID so
12+
// tests can assert whether handlers invalidate it.
13+
func stubCacheEntry(t *testing.T, s *Server, netID uint16) {
14+
t.Helper()
15+
c := &listNodesCacheState{
16+
fullBody: []byte(`{"sentinel":true}`),
17+
builtAt: time.Now(),
18+
}
19+
c.cond = sync.NewCond(&c.mu)
20+
s.listNodesPerNetMu.Lock()
21+
s.listNodesPerNet[netID] = c
22+
s.listNodesPerNetMu.Unlock()
23+
}
24+
25+
// hasCacheEntry returns true if a per-network cache state still exists.
26+
func hasCacheEntry(s *Server, netID uint16) bool {
27+
s.listNodesPerNetMu.Lock()
28+
defer s.listNodesPerNetMu.Unlock()
29+
_, ok := s.listNodesPerNet[netID]
30+
return ok
31+
}
32+
33+
// seedNetworkAndNode installs a network with a single node directly in
34+
// server state. Bypasses the create/register handlers so tests don't have
35+
// to deal with crypto identity. Returns the netID + nodeID.
36+
func seedNetworkAndNode(t *testing.T, s *Server) (uint16, uint32) {
37+
t.Helper()
38+
const netID uint16 = 42
39+
const nodeID uint32 = 1001
40+
41+
s.mu.Lock()
42+
defer s.mu.Unlock()
43+
s.networks[netID] = &NetworkInfo{
44+
ID: netID,
45+
Name: "test-net",
46+
JoinRule: "open",
47+
Members: []uint32{nodeID},
48+
MemberRoles: map[uint32]Role{
49+
nodeID: RoleOwner,
50+
},
51+
Created: time.Now(),
52+
}
53+
n := &NodeInfo{
54+
ID: nodeID,
55+
Owner: "test",
56+
PublicKey: []byte("fake-pk"),
57+
Networks: []uint16{netID},
58+
}
59+
n.setLastSeen(time.Now())
60+
s.nodes[nodeID] = n
61+
if s.nextNet <= netID {
62+
s.nextNet = netID + 1
63+
}
64+
if s.nextNode <= nodeID {
65+
s.nextNode = nodeID + 1
66+
}
67+
return netID, nodeID
68+
}
69+
70+
// TestInvalidateListNodesCacheForNetworkDropsEntry validates the helper
71+
// itself (used by all mutation paths). Smoke test for the contract.
72+
func TestInvalidateListNodesCacheForNetworkDropsEntry(t *testing.T) {
73+
s := newTestServer(t, "")
74+
stubCacheEntry(t, s, 42)
75+
if !hasCacheEntry(s, 42) {
76+
t.Fatalf("setup: expected cache entry for net 42")
77+
}
78+
s.invalidateListNodesCacheForNetwork(42)
79+
if hasCacheEntry(s, 42) {
80+
t.Fatalf("cache entry for net 42 should be gone after invalidate")
81+
}
82+
}
83+
84+
// TestHandleDeleteNetworkInvalidatesCache locks down the rc3 fix: when a
85+
// network is deleted, its cached list_nodes response must be dropped so we
86+
// don't leak orphaned cache memory and don't return zombie data if the same
87+
// netID is reused.
88+
func TestHandleDeleteNetworkInvalidatesCache(t *testing.T) {
89+
s := newTestServer(t, "ADM")
90+
netID, _ := seedNetworkAndNode(t, s)
91+
92+
stubCacheEntry(t, s, netID)
93+
if !hasCacheEntry(s, netID) {
94+
t.Fatalf("setup: cache entry should exist for net %d", netID)
95+
}
96+
97+
if _, err := s.handleDeleteNetwork(map[string]interface{}{
98+
"network_id": float64(netID),
99+
"admin_token": "ADM",
100+
}); err != nil {
101+
t.Fatalf("delete_network: %v", err)
102+
}
103+
104+
if hasCacheEntry(s, netID) {
105+
t.Fatalf("cache entry for net %d should be invalidated after delete_network", netID)
106+
}
107+
}
108+
109+
// TestHandleJoinNetworkInvalidatesCache pins the existing wiring at
110+
// server.go:3403 so a future refactor can't accidentally drop it.
111+
func TestHandleJoinNetworkInvalidatesCache(t *testing.T) {
112+
s := newTestServer(t, "ADM")
113+
114+
// Pre-existing network with no members; we'll join it.
115+
const netID uint16 = 50
116+
const nodeID uint32 = 2001
117+
s.mu.Lock()
118+
s.networks[netID] = &NetworkInfo{
119+
ID: netID,
120+
Name: "join-test",
121+
JoinRule: "open",
122+
Members: []uint32{},
123+
Created: time.Now(),
124+
}
125+
s.nodes[nodeID] = &NodeInfo{
126+
ID: nodeID,
127+
Owner: "alice",
128+
PublicKey: []byte("pk"),
129+
}
130+
s.nodes[nodeID].setLastSeen(time.Now())
131+
if s.nextNet <= netID {
132+
s.nextNet = netID + 1
133+
}
134+
if s.nextNode <= nodeID {
135+
s.nextNode = nodeID + 1
136+
}
137+
s.mu.Unlock()
138+
139+
stubCacheEntry(t, s, netID)
140+
if !hasCacheEntry(s, netID) {
141+
t.Fatalf("setup: cache should exist for net %d", netID)
142+
}
143+
144+
if _, err := s.handleJoinNetwork(map[string]interface{}{
145+
"node_id": float64(nodeID),
146+
"network_id": float64(netID),
147+
"admin_token": "ADM",
148+
}); err != nil {
149+
t.Fatalf("join_network: %v", err)
150+
}
151+
152+
if hasCacheEntry(s, netID) {
153+
t.Fatalf("cache entry for net %d should be invalidated after join_network", netID)
154+
}
155+
}
156+
157+
// TestHandleLeaveNetworkInvalidatesCache pins the wiring at server.go:3493.
158+
func TestHandleLeaveNetworkInvalidatesCache(t *testing.T) {
159+
s := newTestServer(t, "ADM")
160+
netID, nodeID := seedNetworkAndNode(t, s)
161+
162+
stubCacheEntry(t, s, netID)
163+
if !hasCacheEntry(s, netID) {
164+
t.Fatalf("setup: cache should exist for net %d", netID)
165+
}
166+
167+
if _, err := s.handleLeaveNetwork(map[string]interface{}{
168+
"node_id": float64(nodeID),
169+
"network_id": float64(netID),
170+
"admin_token": "ADM",
171+
}); err != nil {
172+
t.Fatalf("leave_network: %v", err)
173+
}
174+
175+
if hasCacheEntry(s, netID) {
176+
t.Fatalf("cache entry for net %d should be invalidated after leave_network", netID)
177+
}
178+
}
179+
180+
// TestConcurrentInvalidateAndPopulateRace exercises the cache map under
181+
// `-race` with simultaneous invalidate + manual populate to catch any
182+
// missing locking on the listNodesPerNet map. Run with `go test -race`.
183+
func TestConcurrentInvalidateAndPopulateRace(t *testing.T) {
184+
s := newTestServer(t, "")
185+
186+
const netID uint16 = 7
187+
const iterations = 500
188+
189+
var wg sync.WaitGroup
190+
wg.Add(2)
191+
go func() {
192+
defer wg.Done()
193+
for i := 0; i < iterations; i++ {
194+
stubCacheEntry(t, s, netID)
195+
}
196+
}()
197+
go func() {
198+
defer wg.Done()
199+
for i := 0; i < iterations; i++ {
200+
s.invalidateListNodesCacheForNetwork(netID)
201+
}
202+
}()
203+
wg.Wait()
204+
}

pkg/registry/delta.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const (
2424
DeltaNetworkLeave DeltaType = 11
2525
DeltaKeyRotation DeltaType = 12
2626
DeltaTaskExec DeltaType = 13
27+
// DeltaNetworkDelete added for WAL wiring (2026-04-29). On older
28+
// binaries that don't recognize this type, replay logs and skips —
29+
// safe forward-compat.
30+
DeltaNetworkDelete DeltaType = 14
2731
)
2832

2933
// DeltaEntry records a single state mutation for incremental replication.

0 commit comments

Comments
 (0)