fix: architecture audit — security, atomicity, broken endpoints, test coverage
Sprint 1 — Security & correctness:
- Restore all 10 commented-out is_local_request() checks (vault, containers, images, volumes)
- Fix XFF spoofing: only trust the LAST X-Forwarded-For entry (Caddy's append), not all
- Require prefix length in wireguard.address (was accepting bare IPs like 10.0.0.1)
- Validate service_access list in add_peer (valid: calendar/files/mail/webdav)
- Fix dhcp/reservations POST/DELETE: unpack mac/ip/hostname from body (was passing dict as positional arg)
- Fix network/test POST: remove spurious data arg (test_connectivity takes no args)
- Fix remove_peer: clear iptables rules and regenerate DNS ACLs on deletion (was leaving stale rules)
- Fix CoreDNS reload: SIGHUP → SIGUSR1 (SIGHUP kills the process; SIGUSR1 triggers reload plugin)
- Remove local.{domain} block from Corefile template (local.zone doesn't exist, caused log spam)
- Fix routing_manager._remove_nat_rule: targeted -D instead of flushing entire POSTROUTING chain
Sprint 2 — State consistency:
- Atomic config writes in config_manager, ip_utils, firewall_manager, network_manager
(write to .tmp → fsync → os.replace, prevents truncated files on kill)
- backup_config: now also backs up Caddyfile, Corefile, .env, DNS zone files
- restore_config: restores all of the above so config stays consistent after restore
Sprint 3 — Dead code / documentation:
- Remove CellManager instantiation from app startup (was never called, double-instantiated all managers)
- Document routing_manager scope (targets host, not cell-wireguard; methods not called by any active route)
Sprint 4 — Test infrastructure:
- Add tests/conftest.py with shared tmp_dir, tmp_config_dir, tmp_data_dir, flask_client fixtures
- Add tests/test_config_validation.py: 400 paths for ip_range, port, wireguard.address validation
- Add tests/test_ip_utils_caddyfile.py: 14 tests for write_caddyfile (was completely untested)
- Expand test_app_misc.py: 7 new is_local_request tests covering XFF spoofing and cell-network IPs
- Add --cov-fail-under=70 to make test-coverage
- Add pre-commit hook that runs pytest before every commit
414 tests pass (was 372).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
-35
@@ -179,7 +179,6 @@ email_manager = EmailManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
calendar_manager = CalendarManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
file_manager = FileManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
routing_manager = RoutingManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
cell_manager = CellManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
container_manager = ContainerManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
||||
cell_link_manager = CellLinkManager(
|
||||
@@ -345,10 +344,12 @@ def is_local_request():
|
||||
|
||||
if _allowed(remote_addr):
|
||||
return True
|
||||
# Only trust the LAST X-Forwarded-For entry — that is what Caddy appended.
|
||||
# Iterating all entries allows clients to spoof local origin by prepending 127.0.0.1.
|
||||
if forwarded_for:
|
||||
for addr in forwarded_for.split(','):
|
||||
if _allowed(addr.strip()):
|
||||
return True
|
||||
last_hop = forwarded_for.split(',')[-1].strip()
|
||||
if _allowed(last_hop):
|
||||
return True
|
||||
return False
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
@@ -481,6 +482,8 @@ def update_config():
|
||||
_addr = data['wireguard'].get('address')
|
||||
if _addr:
|
||||
import ipaddress as _ipa2
|
||||
if '/' not in str(_addr):
|
||||
return jsonify({'error': 'wireguard.address must include a prefix length (e.g. 10.0.0.1/24)'}), 400
|
||||
try:
|
||||
_ipa2.ip_interface(_addr)
|
||||
except ValueError as _e:
|
||||
@@ -1166,10 +1169,13 @@ def get_dhcp_leases():
|
||||
def add_dhcp_reservation():
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
result = network_manager.add_dhcp_reservation(data)
|
||||
return jsonify(result)
|
||||
for field in ('mac', 'ip'):
|
||||
if field not in data:
|
||||
return jsonify({"error": f"Missing required field: {field}"}), 400
|
||||
result = network_manager.add_dhcp_reservation(data['mac'], data['ip'], data.get('hostname', ''))
|
||||
return jsonify({"success": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding DHCP reservation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -1179,8 +1185,10 @@ def remove_dhcp_reservation():
|
||||
"""Remove DHCP reservation."""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
result = network_manager.remove_dhcp_reservation(data)
|
||||
return jsonify(result)
|
||||
if not data or 'mac' not in data:
|
||||
return jsonify({"error": "Missing required field: mac"}), 400
|
||||
result = network_manager.remove_dhcp_reservation(data['mac'])
|
||||
return jsonify({"success": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing DHCP reservation: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -1218,10 +1226,7 @@ def get_dns_status():
|
||||
@app.route('/api/network/test', methods=['POST'])
|
||||
def test_network():
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
result = network_manager.test_connectivity(data)
|
||||
result = network_manager.test_connectivity()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing network: {e}")
|
||||
@@ -1572,6 +1577,12 @@ def add_peer():
|
||||
|
||||
assigned_ip = data.get('ip') or _next_peer_ip()
|
||||
|
||||
# Validate service_access if provided
|
||||
_valid_services = {'calendar', 'files', 'mail', 'webdav'}
|
||||
service_access = data.get('service_access', list(_valid_services))
|
||||
if not isinstance(service_access, list) or not all(s in _valid_services for s in service_access):
|
||||
return jsonify({"error": f"service_access must be a list of: {sorted(_valid_services)}"}), 400
|
||||
|
||||
# Add peer to registry with all provided fields
|
||||
peer_info = {
|
||||
'peer': data['name'],
|
||||
@@ -1584,7 +1595,7 @@ def add_peer():
|
||||
'persistent_keepalive': data.get('persistent_keepalive'),
|
||||
'description': data.get('description'),
|
||||
'internet_access': data.get('internet_access', True),
|
||||
'service_access': data.get('service_access', ['calendar', 'files', 'mail', 'webdav']),
|
||||
'service_access': service_access,
|
||||
'peer_access': data.get('peer_access', True),
|
||||
'config_needs_reinstall': False,
|
||||
}
|
||||
@@ -1651,10 +1662,17 @@ def clear_peer_reinstall(peer_name):
|
||||
|
||||
@app.route('/api/peers/<peer_name>', methods=['DELETE'])
|
||||
def remove_peer(peer_name):
|
||||
"""Remove a peer."""
|
||||
"""Remove a peer and clean up its firewall rules and DNS ACLs."""
|
||||
try:
|
||||
peer = peer_registry.get_peer(peer_name)
|
||||
if not peer:
|
||||
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
|
||||
peer_ip = peer.get('ip')
|
||||
success = peer_registry.remove_peer(peer_name)
|
||||
if success:
|
||||
if peer_ip:
|
||||
firewall_manager.clear_peer_rules(peer_ip)
|
||||
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain())
|
||||
return jsonify({"message": f"Peer {peer_name} removed successfully"})
|
||||
else:
|
||||
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
|
||||
@@ -2558,8 +2576,8 @@ def restart_container(name):
|
||||
@app.route('/api/containers/<name>/logs', methods=['GET'])
|
||||
def get_container_logs(name):
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
tail = request.args.get('tail', default=100, type=int)
|
||||
try:
|
||||
logs = container_manager.get_container_logs(name, tail=tail)
|
||||
@@ -2571,8 +2589,8 @@ def get_container_logs(name):
|
||||
@app.route('/api/containers/<name>/stats', methods=['GET'])
|
||||
def get_container_stats(name):
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
try:
|
||||
stats = container_manager.get_container_stats(name)
|
||||
return jsonify(stats)
|
||||
@@ -2583,16 +2601,16 @@ def get_container_stats(name):
|
||||
@app.route('/api/vault/secrets', methods=['GET'])
|
||||
def list_secrets():
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
secrets = app.vault_manager.list_secrets()
|
||||
return jsonify({'secrets': secrets})
|
||||
|
||||
@app.route('/api/vault/secrets', methods=['POST'])
|
||||
def store_secret():
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'name' not in data or 'value' not in data:
|
||||
return jsonify({'error': 'Missing name or value'}), 400
|
||||
@@ -2602,8 +2620,8 @@ def store_secret():
|
||||
@app.route('/api/vault/secrets/<name>', methods=['GET'])
|
||||
def get_secret(name):
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
value = app.vault_manager.get_secret(name)
|
||||
if value is None:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
@@ -2612,8 +2630,8 @@ def get_secret(name):
|
||||
@app.route('/api/vault/secrets/<name>', methods=['DELETE'])
|
||||
def delete_secret(name):
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
result = app.vault_manager.delete_secret(name)
|
||||
return jsonify({'deleted': result})
|
||||
|
||||
@@ -2621,8 +2639,8 @@ def delete_secret(name):
|
||||
@app.route('/api/containers', methods=['POST'])
|
||||
def create_container():
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not data or 'image' not in data:
|
||||
return jsonify({'error': 'Missing image parameter'}), 400
|
||||
@@ -2653,8 +2671,8 @@ def create_container():
|
||||
@app.route('/api/containers/<name>', methods=['DELETE'])
|
||||
def remove_container(name):
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
force = request.args.get('force', default=False, type=bool)
|
||||
success = container_manager.remove_container(name, force=force)
|
||||
return jsonify({'removed': success})
|
||||
@@ -2662,8 +2680,8 @@ def remove_container(name):
|
||||
@app.route('/api/images', methods=['GET'])
|
||||
def list_images():
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
images = container_manager.list_images()
|
||||
return jsonify(images)
|
||||
|
||||
@@ -2690,8 +2708,8 @@ def remove_image(image):
|
||||
@app.route('/api/volumes', methods=['GET'])
|
||||
def list_volumes():
|
||||
# Temporarily disable access control for debugging
|
||||
# if not is_local_request():
|
||||
# return jsonify({'error': 'Access denied'}), 403
|
||||
if not is_local_request():
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
volumes = container_manager.list_volumes()
|
||||
return jsonify(volumes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user