Files
pic/api/peer_registry.py
T
roof a43f9fbf0d fix: full security audit remediation — P0/P1/P2/P3 fixes + 1020 passing tests
P0 — Broken functionality:
- Fix 12+ endpoints with wrong manager method signatures (email/calendar/file/routing)
- Fix email_manager.delete_email_user() missing domain arg
- Fix cell-link DNS forwarding wiped on every peer change (generate_corefile now
  accepts cell_links param; add/remove_cell_dns_forward no longer clobber the file)
- Fix Flask SECRET_KEY regenerating on every restart (persisted to DATA_DIR)
- Fix _next_peer_ip exhaustion returning 500 instead of 409
- Fix ConfigManager Caddyfile path (/app/config-caddy/)
- Fix UI double-add and wrong-key peer bugs in Peers.jsx / WireGuard.jsx
- Remove hardcoded credentials from Dashboard.jsx

P1 — Security:
- CSRF token validation on all POST/PUT/DELETE/PATCH to /api/* (double-submit pattern)
- enforce_auth: 503 only when users file readable but empty; never bypass on IOError
- WireGuard add_cell_peer: validate pubkey, name, endpoint against strict regexes
- DNS add_cell_dns_forward: validate IP and domain; reject injection chars
- DNS zone write: realpath containment + record content validation
- iptables comment /32 suffix prevents substring match deleting wrong peer rules
- is_local_request() trusts only loopback + 172.16.0.0/12 (Docker bridge)
- POST /api/containers: volume allow-list prevents arbitrary host mounts
- file_manager: bcrypt ($2b→$2y) for WebDAV; realpath containment in delete_user
- email/calendar: stop persisting plaintext passwords in user records
- routing_manager: validate IPs, networks, and interface names
- peer_registry: write peers.json at mode 0o600
- vault_manager: Fernet key file at mode 0o600
- CORS: lock down to explicit origin list
- domain/cell_name validation: reject newline, brace, semicolon injection chars

P2 — Architecture:
- Peer add: rollback registry entry if firewall rules fail post-add
- restart_service(): base class now calls _restart_container(); email and calendar
  managers call cell-mail / cell-radicale respectively
- email/calendar managers sync user list (no passwords) to cell_config.json
- Pending-restart flag cleared only after helper subprocess exits with code 0
- docker-compose.yml: add config-caddy volume to API container

P3 — Tests (854 → 1020):
- Fill test_email_endpoints.py, test_calendar_endpoints.py,
  test_network_endpoints.py, test_routing_endpoints.py
- New: test_peer_management_update.py, test_peer_management_edge_cases.py,
  test_input_validation.py, test_enforce_auth_configured.py,
  test_cell_link_dns.py, test_logs_endpoints.py, test_cells_endpoints.py,
  test_is_local_request_per_endpoint.py, test_caddy_routing.py
- E2E conftest: skip WireGuard suite when wg-quick absent
- Update existing tests to match fixed signatures and comment formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:30:21 -04:00

360 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Peer Registry for Personal Internet Cell
Handles peer registration and management
"""
import json
import os
import logging
from threading import RLock
from datetime import datetime
from typing import Dict, List, Any, Optional
from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__)
class PeerRegistry(BaseServiceManager):
"""Manages peer registration and management"""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
super().__init__('peer_registry', data_dir, config_dir)
self.lock = RLock()
self.peers = []
self.peers_file = os.path.join(data_dir, 'peers.json')
self._load_peers()
def get_status(self) -> Dict[str, Any]:
"""Get peer registry status"""
try:
with self.lock:
status = {
'running': True,
'status': 'online',
'peers_count': len(self.peers),
'active_peers': len([p for p in self.peers if p.get('active', True)]),
'inactive_peers': len([p for p in self.peers if not p.get('active', True)]),
'last_updated': datetime.utcnow().isoformat(),
'timestamp': datetime.utcnow().isoformat()
}
return status
except Exception as e:
return self.handle_error(e, "get_status")
def test_connectivity(self) -> Dict[str, Any]:
"""Test peer registry connectivity"""
try:
# Test file system access
fs_test = self._test_filesystem_access()
# Test peer data integrity
integrity_test = self._test_data_integrity()
# Test peer operations
operations_test = self._test_peer_operations()
results = {
'filesystem_access': fs_test,
'data_integrity': integrity_test,
'peer_operations': operations_test,
'success': fs_test.get('success', False) and integrity_test.get('success', False),
'timestamp': datetime.utcnow().isoformat()
}
return results
except Exception as e:
return self.handle_error(e, "test_connectivity")
def _test_filesystem_access(self) -> Dict[str, Any]:
"""Test filesystem access for peer data"""
try:
# Test if we can read/write to the peers file
test_peer = {
'peer': 'test_peer',
'ip': '192.168.1.100',
'public_key': 'test_key',
'active': False,
'test': True
}
# Test write
with self.lock:
original_peers = self.peers.copy()
self.peers.append(test_peer)
self._save_peers()
# Test read
with self.lock:
loaded_peers = self.peers.copy()
# Remove test peer
self.peers = [p for p in self.peers if not p.get('test', False)]
self._save_peers()
# Restore original state
with self.lock:
self.peers = original_peers
self._save_peers()
return {
'success': True,
'message': 'Filesystem access working',
'read_write': True
}
except Exception as e:
return {
'success': False,
'message': f'Filesystem access failed: {str(e)}',
'error': str(e)
}
def _test_data_integrity(self) -> Dict[str, Any]:
"""Test peer data integrity"""
try:
with self.lock:
# Check if peers data is valid JSON
peers_copy = self.peers.copy()
# Validate peer structure
valid_peers = 0
invalid_peers = 0
for peer in peers_copy:
if isinstance(peer, dict) and 'peer' in peer and 'ip' in peer:
valid_peers += 1
else:
invalid_peers += 1
return {
'success': True,
'message': 'Data integrity check passed',
'valid_peers': valid_peers,
'invalid_peers': invalid_peers,
'total_peers': len(peers_copy)
}
except Exception as e:
return {
'success': False,
'message': f'Data integrity check failed: {str(e)}',
'error': str(e)
}
def _test_peer_operations(self) -> Dict[str, Any]:
"""Test peer operations"""
try:
# Test adding a peer
test_peer = {
'peer': 'test_operation_peer',
'ip': '192.168.1.101',
'public_key': 'test_operation_key',
'active': False,
'test': True
}
# Test add
add_success = self.add_peer(test_peer)
# Test get
retrieved_peer = self.get_peer('test_operation_peer')
get_success = retrieved_peer is not None
# Test update
update_success = self.update_peer_ip('test_operation_peer', '192.168.1.102')
# Test remove
remove_success = self.remove_peer('test_operation_peer')
return {
'success': add_success and get_success and update_success and remove_success,
'message': 'Peer operations working',
'add_success': add_success,
'get_success': get_success,
'update_success': update_success,
'remove_success': remove_success
}
except Exception as e:
return {
'success': False,
'message': f'Peer operations test failed: {str(e)}',
'error': str(e)
}
def _load_peers(self):
"""Load peers from file"""
try:
# Ensure directory exists
os.makedirs(os.path.dirname(self.peers_file), exist_ok=True)
if os.path.exists(self.peers_file):
with open(self.peers_file, 'r') as f:
try:
self.peers = json.load(f)
self.logger.info(f"Loaded {len(self.peers)} peers from file")
except Exception as e:
self.logger.error(f"Error loading peers: {e}")
self.peers = []
else:
self.peers = []
self.logger.info("No peers file found, starting with empty registry")
except Exception as e:
self.logger.error(f"Error in _load_peers: {e}")
self.peers = []
def _save_peers(self):
"""Save peers to file"""
try:
# Ensure directory exists
os.makedirs(os.path.dirname(self.peers_file), exist_ok=True)
# Write to a temp file with restrictive perms, then atomically replace.
# peers.json contains WireGuard private keys — must never be world-readable.
tmp_path = self.peers_file + '.tmp'
# Open with O_CREAT|O_WRONLY|O_TRUNC and mode 0o600 so the file is
# created with restrictive permissions from the very first byte.
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, 'w') as f:
json.dump(self.peers, f, indent=2)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# Ensure perms are 0o600 even if umask or prior file affected them.
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, self.peers_file)
# Belt-and-braces: also chmod the destination in case it pre-existed
# with looser perms on a filesystem that preserves the destination's mode.
os.chmod(self.peers_file, 0o600)
self.logger.info(f"Saved {len(self.peers)} peers to file")
except Exception as e:
self.logger.error(f"Error saving peers: {e}")
def list_peers(self) -> List[Dict[str, Any]]:
"""List all peers"""
with self.lock:
return list(self.peers)
def get_peer(self, name: str) -> Optional[Dict[str, Any]]:
"""Get a specific peer by name"""
with self.lock:
for peer in self.peers:
if peer.get('peer') == name:
return peer
return None
def add_peer(self, peer_info: Dict[str, Any]) -> bool:
"""Add a new peer"""
try:
with self.lock:
if self.get_peer(peer_info.get('peer')):
self.logger.warning(f"Peer {peer_info.get('peer')} already exists")
return False
# Add timestamp
peer_info['created_at'] = datetime.utcnow().isoformat()
peer_info['active'] = peer_info.get('active', True)
self.peers.append(peer_info)
self._save_peers()
self.logger.info(f"Added peer: {peer_info.get('peer')}")
return True
except Exception as e:
self.logger.error(f"Error adding peer: {e}")
return False
def remove_peer(self, name: str) -> bool:
"""Remove a peer"""
try:
with self.lock:
before = len(self.peers)
self.peers = [p for p in self.peers if p.get('peer') != name]
self._save_peers()
removed = len(self.peers) < before
if removed:
self.logger.info(f"Removed peer: {name}")
else:
self.logger.warning(f"Peer {name} not found for removal")
return removed
except Exception as e:
self.logger.error(f"Error removing peer {name}: {e}")
return False
def update_peer(self, name: str, fields: Dict[str, Any]) -> bool:
"""Update arbitrary fields on a peer."""
try:
with self.lock:
for peer in self.peers:
if peer.get('peer') == name:
peer.update(fields)
peer['updated_at'] = datetime.utcnow().isoformat()
self._save_peers()
self.logger.info(f"Updated peer {name}: {list(fields.keys())}")
return True
self.logger.warning(f"Peer {name} not found for update")
return False
except Exception as e:
self.logger.error(f"Error updating peer {name}: {e}")
return False
def clear_reinstall_flag(self, name: str) -> bool:
"""Clear the config_needs_reinstall flag after user downloads new config."""
return self.update_peer(name, {'config_needs_reinstall': False})
def update_peer_ip(self, name: str, new_ip: str) -> bool:
"""Update peer IP address"""
try:
with self.lock:
for peer in self.peers:
if peer.get('peer') == name:
old_ip = peer.get('ip')
peer['ip'] = new_ip
peer['updated_at'] = datetime.utcnow().isoformat()
self._save_peers()
self.logger.info(f"Updated peer {name} IP from {old_ip} to {new_ip}")
return True
self.logger.warning(f"Peer {name} not found for IP update")
return False
except Exception as e:
self.logger.error(f"Error updating peer {name} IP: {e}")
return False
def get_peer_stats(self) -> Dict[str, Any]:
"""Get peer registry statistics"""
try:
with self.lock:
active_peers = [p for p in self.peers if p.get('active', True)]
inactive_peers = [p for p in self.peers if not p.get('active', True)]
# Count peers by IP range
ip_ranges = {}
for peer in self.peers:
ip = peer.get('ip', '')
if ip:
range_key = '.'.join(ip.split('.')[:3]) + '.0/24'
ip_ranges[range_key] = ip_ranges.get(range_key, 0) + 1
return {
'total_peers': len(self.peers),
'active_peers': len(active_peers),
'inactive_peers': len(inactive_peers),
'ip_ranges': ip_ranges,
'timestamp': datetime.utcnow().isoformat()
}
except Exception as e:
self.logger.error(f"Error getting peer stats: {e}")
return {
'total_peers': 0,
'active_peers': 0,
'inactive_peers': 0,
'ip_ranges': {},
'error': str(e),
'timestamp': datetime.utcnow().isoformat()
}