diff --git a/node/peer.go b/node/peer.go index d4ff02c..5054949 100644 --- a/node/peer.go +++ b/node/peer.go @@ -101,6 +101,7 @@ type PeerRegistry struct { authMode PeerAuthMode // How to handle unknown peers allowedPublicKeys map[string]bool // Allowlist of public keys (when authMode is Allowlist) allowedPublicKeyMu sync.RWMutex // Protects allowedPublicKeys + allowlistPath string // Sidecar file for persisted allowlist keys // Debounce disk writes dirty bool // Whether there are unsaved changes @@ -135,6 +136,7 @@ func NewPeerRegistryWithPath(peersPath string) (*PeerRegistry, error) { pr := &PeerRegistry{ peers: make(map[string]*Peer), path: peersPath, + allowlistPath: peersPath + ".allowlist.json", stopChan: make(chan struct{}), authMode: PeerAuthOpen, // Default to open for backward compatibility allowedPublicKeys: make(map[string]bool), @@ -144,7 +146,12 @@ func NewPeerRegistryWithPath(peersPath string) (*PeerRegistry, error) { if err := pr.load(); err != nil { // No existing peers, that's ok pr.rebuildKDTree() - return pr, nil + } + + // Load any persisted allowlist entries. This is best effort so that a + // missing or corrupt sidecar does not block peer registry startup. + if err := pr.loadAllowedPublicKeys(); err != nil { + logging.Warn("failed to load peer allowlist", logging.Fields{"error": err}) } pr.rebuildKDTree() @@ -169,17 +176,25 @@ func (r *PeerRegistry) GetAuthMode() PeerAuthMode { // AllowPublicKey adds a public key to the allowlist. func (r *PeerRegistry) AllowPublicKey(publicKey string) { r.allowedPublicKeyMu.Lock() - defer r.allowedPublicKeyMu.Unlock() r.allowedPublicKeys[publicKey] = true + r.allowedPublicKeyMu.Unlock() logging.Debug("public key added to allowlist", logging.Fields{"key": safeKeyPrefix(publicKey)}) + + if err := r.saveAllowedPublicKeys(); err != nil { + logging.Warn("failed to persist peer allowlist", logging.Fields{"error": err}) + } } // RevokePublicKey removes a public key from the allowlist. func (r *PeerRegistry) RevokePublicKey(publicKey string) { r.allowedPublicKeyMu.Lock() - defer r.allowedPublicKeyMu.Unlock() delete(r.allowedPublicKeys, publicKey) + r.allowedPublicKeyMu.Unlock() logging.Debug("public key removed from allowlist", logging.Fields{"key": safeKeyPrefix(publicKey)}) + + if err := r.saveAllowedPublicKeys(); err != nil { + logging.Warn("failed to persist peer allowlist", logging.Fields{"error": err}) + } } // IsPublicKeyAllowed checks if a public key is in the allowlist. @@ -708,6 +723,72 @@ func (r *PeerRegistry) Close() error { return nil } +// saveAllowedPublicKeys persists the allowlist to disk immediately. +// It keeps the allowlist in a separate sidecar file so peer persistence remains +// backwards compatible with the existing peers.json array format. +func (r *PeerRegistry) saveAllowedPublicKeys() error { + r.allowedPublicKeyMu.RLock() + keys := make([]string, 0, len(r.allowedPublicKeys)) + for key := range r.allowedPublicKeys { + keys = append(keys, key) + } + r.allowedPublicKeyMu.RUnlock() + + slices.Sort(keys) + + dir := filepath.Dir(r.allowlistPath) + if err := coreio.Local.EnsureDir(dir); err != nil { + return coreerr.E("PeerRegistry.saveAllowedPublicKeys", "failed to create allowlist directory", err) + } + + data, err := json.MarshalIndent(keys, "", " ") + if err != nil { + return coreerr.E("PeerRegistry.saveAllowedPublicKeys", "failed to marshal allowlist", err) + } + + tmpPath := r.allowlistPath + ".tmp" + if err := coreio.Local.Write(tmpPath, string(data)); err != nil { + return coreerr.E("PeerRegistry.saveAllowedPublicKeys", "failed to write allowlist temp file", err) + } + + if err := coreio.Local.Rename(tmpPath, r.allowlistPath); err != nil { + coreio.Local.Delete(tmpPath) + return coreerr.E("PeerRegistry.saveAllowedPublicKeys", "failed to rename allowlist file", err) + } + + return nil +} + +// loadAllowedPublicKeys loads the allowlist from disk. +func (r *PeerRegistry) loadAllowedPublicKeys() error { + if !coreio.Local.Exists(r.allowlistPath) { + return nil + } + + content, err := coreio.Local.Read(r.allowlistPath) + if err != nil { + return coreerr.E("PeerRegistry.loadAllowedPublicKeys", "failed to read allowlist", err) + } + + var keys []string + if err := json.Unmarshal([]byte(content), &keys); err != nil { + return coreerr.E("PeerRegistry.loadAllowedPublicKeys", "failed to unmarshal allowlist", err) + } + + r.allowedPublicKeyMu.Lock() + defer r.allowedPublicKeyMu.Unlock() + + r.allowedPublicKeys = make(map[string]bool, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + r.allowedPublicKeys[key] = true + } + + return nil +} + // save is a helper that schedules a debounced save. // Kept for backward compatibility but now debounces writes. // Must NOT be called with r.mu held. diff --git a/node/peer_test.go b/node/peer_test.go index 9653cbe..7960f6f 100644 --- a/node/peer_test.go +++ b/node/peer_test.go @@ -389,6 +389,39 @@ func TestPeerRegistry_Persistence(t *testing.T) { } } +func TestPeerRegistry_AllowlistPersistence(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "allowlist-persist-test") + defer os.RemoveAll(tmpDir) + + peersPath := filepath.Join(tmpDir, "peers.json") + + pr1, err := NewPeerRegistryWithPath(peersPath) + if err != nil { + t.Fatalf("failed to create first registry: %v", err) + } + + key := "allowlist-key-1234567890" + pr1.AllowPublicKey(key) + + if err := pr1.Close(); err != nil { + t.Fatalf("failed to close first registry: %v", err) + } + + pr2, err := NewPeerRegistryWithPath(peersPath) + if err != nil { + t.Fatalf("failed to create second registry: %v", err) + } + + if !pr2.IsPublicKeyAllowed(key) { + t.Fatal("expected allowlisted key to survive reload") + } + + keys := pr2.ListAllowedPublicKeys() + if !slices.Contains(keys, key) { + t.Fatalf("expected allowlisted key to be listed after reload, got %v", keys) + } +} + // --- Security Feature Tests --- func TestPeerRegistry_AuthMode(t *testing.T) {