feat(node): persist peer allowlist
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
8d1caa3a59
commit
ee623a7343
2 changed files with 117 additions and 3 deletions
87
node/peer.go
87
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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue