4215e03ac6
Autosave on Apply (was broken):
- App.jsx called useDraftConfig() in the same component that rendered
DraftConfigProvider — a component cannot consume context it provides.
Fixed by splitting into AppCore (consumes context, all logic) and App
(thin shell that wraps AppCore in DraftConfigProvider). The hook now
runs inside the provider and hasDirty()/flushAll() work correctly.
Cell name / domain length validation (255-char DNS standard):
- api/app.py: reject cell_name or domain > 255 chars or empty with 400
- api/app.py: reject ip_range without CIDR prefix (bare IPs shift all VIPs)
- webui/src/pages/Settings.jsx: cellNameError + domainError computed values
block saveIdentity and show inline error; maxLength={255} on inputs
- tests/test_identity_validation.py: 8 unit tests for the new validation
Cell name overflow on all pages:
- Dashboard.jsx: add min-w-0 to flex child div + truncate + title on cell_name
- CellNetwork.jsx: min-w-0 + truncate + title on cell_name, domain, endpoint,
vpn_subnet in invite cards and connected-cells list
Apply-and-verify integration tests:
- tests/integration/test_apply_propagation.py: TestPendingState (no restarts)
and TestApplyAndVerify (triggers real container restart + health poll)
covering the full save → apply → wait → verify propagation lifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2765 lines
110 KiB
Python
2765 lines
110 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Personal Internet Cell API Server
|
|
|
|
Provides REST API endpoints for managing:
|
|
- Cell status and configuration
|
|
- Network services (DNS, DHCP, NTP)
|
|
- WireGuard VPN and peer management
|
|
- Email, Calendar, and File services
|
|
- Routing and VPN gateway
|
|
- Vault and trust management (Phase 6)
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from flask import Flask, request, jsonify, current_app
|
|
from flask_cors import CORS
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
import json as pyjson
|
|
from logging.handlers import RotatingFileHandler
|
|
import uuid
|
|
import contextvars
|
|
|
|
# Track API start time for uptime calculation
|
|
API_START_TIME = time.time()
|
|
|
|
from network_manager import NetworkManager
|
|
from wireguard_manager import WireGuardManager
|
|
from peer_registry import PeerRegistry
|
|
from email_manager import EmailManager
|
|
from calendar_manager import CalendarManager
|
|
from file_manager import FileManager
|
|
from routing_manager import RoutingManager
|
|
from cell_manager import CellManager
|
|
from vault_manager import VaultManager
|
|
from container_manager import ContainerManager
|
|
from config_manager import ConfigManager
|
|
from service_bus import ServiceBus, EventType
|
|
from log_manager import LogManager
|
|
from cell_link_manager import CellLinkManager
|
|
import firewall_manager
|
|
from port_registry import PORT_FIELDS, detect_conflicts
|
|
|
|
# Context variable for request info
|
|
request_context = contextvars.ContextVar('request_context', default={})
|
|
|
|
# Set default log level and log file if not already defined
|
|
LOG_LEVEL = globals().get('LOG_LEVEL', 'INFO')
|
|
LOG_FILE = globals().get('LOG_FILE', 'picell.log')
|
|
|
|
class ContextFilter(logging.Filter):
|
|
def filter(self, record):
|
|
ctx = request_context.get({})
|
|
for k, v in ctx.items():
|
|
setattr(record, k, v)
|
|
return True
|
|
|
|
class JsonFormatter(logging.Formatter):
|
|
def format(self, record):
|
|
log_record = {
|
|
'timestamp': self.formatTime(record, self.datefmt),
|
|
'level': record.levelname,
|
|
'name': record.name,
|
|
'message': record.getMessage(),
|
|
'request_id': getattr(record, 'request_id', None),
|
|
'client_ip': getattr(record, 'client_ip', None),
|
|
'method': getattr(record, 'method', None),
|
|
'path': getattr(record, 'path', None),
|
|
'status': getattr(record, 'status', None),
|
|
'user': getattr(record, 'user', None),
|
|
}
|
|
if record.exc_info:
|
|
log_record['exception'] = self.formatException(record.exc_info)
|
|
return pyjson.dumps({k: v for k, v in log_record.items() if v is not None})
|
|
|
|
json_formatter = JsonFormatter()
|
|
context_filter = ContextFilter()
|
|
|
|
handlers = [logging.StreamHandler()]
|
|
try:
|
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5_000_000, backupCount=5, encoding='utf-8')
|
|
file_handler.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
|
file_handler.setFormatter(json_formatter)
|
|
file_handler.addFilter(context_filter)
|
|
handlers.append(file_handler)
|
|
except Exception as e:
|
|
print(f"Warning: Could not create rotating log file handler: {e}")
|
|
|
|
for h in handlers:
|
|
h.setFormatter(json_formatter)
|
|
h.addFilter(context_filter)
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
|
handlers=handlers
|
|
)
|
|
logger = logging.getLogger('picell')
|
|
|
|
# Flask app setup
|
|
app = Flask(__name__)
|
|
CORS(app)
|
|
|
|
# Development mode flag
|
|
app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production
|
|
|
|
# Initialize enhanced components
|
|
config_manager = ConfigManager(
|
|
config_file=os.path.join(os.environ.get('CONFIG_DIR', '/app/config'), 'cell_config.json'),
|
|
data_dir=os.environ.get('DATA_DIR', '/app/data'),
|
|
)
|
|
service_bus = ServiceBus()
|
|
log_manager = LogManager(log_dir='./data/logs')
|
|
|
|
# Initialize service loggers
|
|
service_log_configs = {
|
|
'network': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'wireguard': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'email': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'calendar': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'files': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'routing': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'vault': {'level': 'INFO', 'formatter': 'json', 'console': False},
|
|
'api': {'level': 'INFO', 'formatter': 'json', 'console': True}
|
|
}
|
|
|
|
for service, config in service_log_configs.items():
|
|
log_manager.add_service_logger(service, config)
|
|
|
|
# Apply any persisted log level overrides
|
|
_levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json')
|
|
if os.path.exists(_levels_file):
|
|
try:
|
|
with open(_levels_file) as _f:
|
|
for _svc, _lvl in json.load(_f).items():
|
|
log_manager.set_service_level(_svc, _lvl)
|
|
except Exception:
|
|
pass
|
|
|
|
# Start service bus
|
|
service_bus.start()
|
|
|
|
@app.before_request
|
|
def enrich_log_context():
|
|
req_id = str(uuid.uuid4())
|
|
client_ip = request.remote_addr
|
|
method = request.method
|
|
path = request.path
|
|
user = getattr(getattr(request, 'user', None), 'id', None) or 'anonymous'
|
|
request_context.set({
|
|
'request_id': req_id,
|
|
'client_ip': client_ip,
|
|
'method': method,
|
|
'path': path,
|
|
'user': user
|
|
})
|
|
|
|
@app.after_request
|
|
def log_request(response):
|
|
ctx = request_context.get({})
|
|
ctx['status'] = response.status_code
|
|
logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}")
|
|
return response
|
|
|
|
@app.teardown_request
|
|
def clear_log_context(exc):
|
|
request_context.set({})
|
|
|
|
# Initialize managers — paths configurable via env for testing
|
|
_DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
|
_CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
|
|
|
network_manager = NetworkManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
|
wireguard_manager = WireGuardManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
|
peer_registry = PeerRegistry(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
|
|
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)
|
|
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(
|
|
data_dir=_DATA_DIR, config_dir=_CONFIG_DIR,
|
|
wireguard_manager=wireguard_manager, network_manager=network_manager,
|
|
)
|
|
|
|
# Apply firewall + DNS rules from stored peer settings (survives API restarts)
|
|
def _configured_domain() -> str:
|
|
return config_manager.configs.get('_identity', {}).get('domain', 'cell')
|
|
|
|
|
|
def _apply_startup_enforcement():
|
|
try:
|
|
peers = peer_registry.list_peers()
|
|
firewall_manager.apply_all_peer_rules(peers)
|
|
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain())
|
|
logger.info(f"Applied enforcement rules for {len(peers)} peers on startup")
|
|
except Exception as e:
|
|
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
|
|
|
|
def _bootstrap_dns():
|
|
try:
|
|
identity = config_manager.configs.get('_identity', {})
|
|
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
|
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
|
ip_range = identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
|
network_manager.bootstrap_dns_records(cell_name, domain, ip_range)
|
|
except Exception as e:
|
|
logger.warning(f"DNS bootstrap failed (non-fatal): {e}")
|
|
|
|
COREFILE_PATH = '/app/config/dns/Corefile'
|
|
|
|
# Run in background so startup isn't blocked waiting on docker exec
|
|
threading.Thread(target=_apply_startup_enforcement, daemon=True).start()
|
|
threading.Thread(target=_bootstrap_dns, daemon=True).start()
|
|
|
|
# Register services with service bus
|
|
service_bus.register_service('network', network_manager)
|
|
service_bus.register_service('wireguard', wireguard_manager)
|
|
service_bus.register_service('email', email_manager)
|
|
service_bus.register_service('calendar', calendar_manager)
|
|
service_bus.register_service('files', file_manager)
|
|
service_bus.register_service('routing', routing_manager)
|
|
service_bus.register_service('vault', app.vault_manager)
|
|
service_bus.register_service('container', container_manager)
|
|
|
|
# Unified health monitoring
|
|
HEALTH_HISTORY_SIZE = 100
|
|
health_history = deque(maxlen=HEALTH_HISTORY_SIZE)
|
|
health_monitor_running = True
|
|
|
|
# Health alerting configuration
|
|
HEALTH_ALERT_THRESHOLD = 3 # Number of consecutive failures before alert
|
|
service_alert_counters = {}
|
|
|
|
def perform_health_check():
|
|
"""Perform a unified health check of all services, with alerting."""
|
|
try:
|
|
# Use service bus to get health from all services
|
|
result = {
|
|
'timestamp': datetime.utcnow().isoformat(),
|
|
'alerts': []
|
|
}
|
|
|
|
# Get health from each service
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
if hasattr(service, 'health_check'):
|
|
health = service.health_check()
|
|
else:
|
|
health = service.get_status()
|
|
result[service_name] = health
|
|
except Exception as e:
|
|
result[service_name] = {'error': str(e), 'status': 'offline'}
|
|
|
|
# Health alerting logic — alert only when a service container is not running
|
|
global service_alert_counters
|
|
for service_name in service_bus.list_services():
|
|
if service_name in result:
|
|
status = result[service_name]
|
|
healthy = True
|
|
|
|
if isinstance(status, dict):
|
|
# Prefer status.running (container actually up) over healthy (connectivity tests)
|
|
inner = status.get('status', {})
|
|
if isinstance(inner, dict):
|
|
if 'running' in inner:
|
|
healthy = inner['running']
|
|
elif 'status' in inner:
|
|
healthy = str(inner['status']).lower() in ('ok', 'healthy', 'online', 'active')
|
|
elif 'running' in status:
|
|
healthy = status['running']
|
|
elif 'error' in status:
|
|
healthy = False
|
|
else:
|
|
healthy = bool(status)
|
|
|
|
# Only count as unhealthy if we're certain it's down
|
|
if not healthy:
|
|
service_alert_counters[service_name] = service_alert_counters.get(service_name, 0) + 1
|
|
if service_alert_counters[service_name] >= HEALTH_ALERT_THRESHOLD:
|
|
alert_msg = f"ALERT: {service_name} unhealthy for {service_alert_counters[service_name]} consecutive checks."
|
|
logger.warning(alert_msg)
|
|
result['alerts'].append(alert_msg)
|
|
|
|
# Publish alert event
|
|
service_bus.publish_event(EventType.ERROR_OCCURRED, service_name, {
|
|
'error': alert_msg,
|
|
'service': service_name,
|
|
'consecutive_failures': service_alert_counters[service_name]
|
|
})
|
|
else:
|
|
# Reset counter if service is healthy
|
|
if service_alert_counters.get(service_name, 0) > 0:
|
|
logger.info(f"Service {service_name} recovered, resetting alert counter")
|
|
service_alert_counters[service_name] = 0
|
|
|
|
logger.info(f"Unified health check: {result}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Unified health check failed: {e}")
|
|
return {'error': str(e), 'timestamp': datetime.utcnow().isoformat()}
|
|
|
|
def health_monitor_loop():
|
|
while health_monitor_running:
|
|
with app.app_context():
|
|
health_result = perform_health_check()
|
|
health_history.appendleft(health_result)
|
|
|
|
# Publish health check event
|
|
service_bus.publish_event(EventType.HEALTH_CHECK, 'api', health_result)
|
|
time.sleep(60) # Check every 60 seconds
|
|
|
|
# Start health monitor thread
|
|
health_monitor_thread = threading.Thread(target=health_monitor_loop, daemon=True)
|
|
health_monitor_thread.start()
|
|
|
|
def is_local_request():
|
|
remote_addr = request.remote_addr
|
|
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
|
|
|
def _allowed(addr):
|
|
if not addr:
|
|
return False
|
|
if addr in ('127.0.0.1', '::1', 'localhost'):
|
|
return True
|
|
try:
|
|
import ipaddress as _ipa
|
|
ip = _ipa.ip_address(addr)
|
|
if ip.is_private or ip.is_loopback:
|
|
return True
|
|
# Also allow IPs in the configured cell-network, which may fall outside
|
|
# RFC-1918 (e.g. 172.0.0.0/24 is not in 172.16.0.0/12).
|
|
cell_net = config_manager.configs.get('_identity', {}).get(
|
|
'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
|
if ip in _ipa.ip_network(cell_net, strict=False):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
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:
|
|
last_hop = forwarded_for.split(',')[-1].strip()
|
|
if _allowed(last_hop):
|
|
return True
|
|
return False
|
|
|
|
@app.route('/health', methods=['GET'])
|
|
def health_check():
|
|
"""Health check endpoint."""
|
|
try:
|
|
return jsonify({
|
|
"status": "healthy",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"version": "1.0.0"
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Health check failed: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/status', methods=['GET'])
|
|
def get_cell_status():
|
|
"""Get overall cell status."""
|
|
try:
|
|
# Use service bus to get status from all services
|
|
services_status = {}
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
services_status[service_name] = service.get_status()
|
|
except Exception as e:
|
|
services_status[service_name] = {'error': str(e)}
|
|
|
|
peers = peer_registry.list_peers()
|
|
|
|
# Calculate actual uptime
|
|
current_time = time.time()
|
|
uptime_seconds = int(current_time - API_START_TIME)
|
|
|
|
identity = config_manager.configs.get('_identity', {})
|
|
return jsonify({
|
|
"cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
|
"domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
|
"uptime": uptime_seconds,
|
|
"peers_count": len(peers),
|
|
"services": services_status,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error getting cell status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config', methods=['GET'])
|
|
def get_config():
|
|
"""Get cell configuration."""
|
|
try:
|
|
service_configs = config_manager.get_all_configs()
|
|
identity = service_configs.pop('_identity', {})
|
|
config = {
|
|
'cell_name': identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
|
'domain': identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
|
'ip_range': identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
|
'wireguard_port': identity.get('wireguard_port', int(os.environ.get('WG_PORT', '51820'))),
|
|
}
|
|
# Expose computed per-service IPs so the frontend doesn't need to derive them
|
|
import ip_utils as _ip_utils_cfg
|
|
_ips = _ip_utils_cfg.get_service_ips(config['ip_range'])
|
|
config['service_ips'] = {
|
|
'dns': _ips['dns'],
|
|
'vip_mail': _ips['vip_mail'],
|
|
'vip_calendar': _ips['vip_calendar'],
|
|
'vip_files': _ips['vip_files'],
|
|
'vip_webdav': _ips['vip_webdav'],
|
|
}
|
|
config['service_configs'] = service_configs
|
|
return jsonify(config)
|
|
except Exception as e:
|
|
logger.error(f"Error getting config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config', methods=['PUT'])
|
|
def update_config():
|
|
"""Update cell configuration."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
|
|
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
|
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
|
|
identity_updates = {k: v for k, v in data.items() if k in identity_keys}
|
|
|
|
# Validate cell_name — must be non-empty and at most 255 characters (DNS limit)
|
|
if 'cell_name' in identity_updates:
|
|
v = str(identity_updates['cell_name'])
|
|
if len(v) > 255:
|
|
return jsonify({'error': 'cell_name must be 255 characters or fewer'}), 400
|
|
if not v:
|
|
return jsonify({'error': 'cell_name cannot be empty'}), 400
|
|
|
|
# Validate domain — must be non-empty and at most 255 characters (DNS limit)
|
|
if 'domain' in identity_updates:
|
|
v = str(identity_updates['domain'])
|
|
if len(v) > 255:
|
|
return jsonify({'error': 'domain must be 255 characters or fewer'}), 400
|
|
if not v:
|
|
return jsonify({'error': 'domain cannot be empty'}), 400
|
|
|
|
# Validate ip_range — must be a valid CIDR within an RFC-1918 range
|
|
if 'ip_range' in identity_updates:
|
|
import ipaddress as _ipa
|
|
_rfc1918 = [
|
|
_ipa.ip_network('10.0.0.0/8'),
|
|
_ipa.ip_network('172.16.0.0/12'),
|
|
_ipa.ip_network('192.168.0.0/16'),
|
|
]
|
|
try:
|
|
_raw = str(identity_updates['ip_range'])
|
|
if '/' not in _raw:
|
|
return jsonify({'error': 'ip_range must include a CIDR prefix (e.g. 172.20.0.0/16)'}), 400
|
|
_net = _ipa.ip_network(_raw, strict=False)
|
|
if not any(_net.subnet_of(r) for r in _rfc1918):
|
|
return jsonify({'error': (
|
|
'ip_range must be within an RFC-1918 private range '
|
|
'(10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16)'
|
|
)}), 400
|
|
except ValueError as _e:
|
|
return jsonify({'error': f'Invalid ip_range: {_e}'}), 400
|
|
|
|
# Validate service config port and IP fields
|
|
_port_fields = {
|
|
'network': ['dns_port'],
|
|
'wireguard': ['port'],
|
|
'email': ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'],
|
|
'calendar': ['port'],
|
|
'files': ['port', 'manager_port'],
|
|
}
|
|
for _svc, _fields in _port_fields.items():
|
|
if _svc not in data:
|
|
continue
|
|
_svc_data = data[_svc]
|
|
if not isinstance(_svc_data, dict):
|
|
continue
|
|
for _f in _fields:
|
|
if _f in _svc_data and _svc_data[_f] is not None and _svc_data[_f] != '':
|
|
try:
|
|
_p = int(_svc_data[_f])
|
|
if not (1 <= _p <= 65535):
|
|
raise ValueError()
|
|
except (ValueError, TypeError):
|
|
return jsonify({'error': f'{_svc}.{_f} must be an integer between 1 and 65535'}), 400
|
|
# Validate that no two service sections use the same port number
|
|
_conflicts = detect_conflicts(config_manager.configs, data)
|
|
if _conflicts:
|
|
_msgs = []
|
|
for _c in _conflicts:
|
|
_pairs = ', '.join(f"{_s}.{_f}" for _s, _f in _c['conflicts'])
|
|
_msgs.append(f"port {_c['port']} is used by {_pairs}")
|
|
return jsonify({'error': 'Port conflict: ' + '; '.join(_msgs)}), 409
|
|
# Validate WireGuard address (must be valid IP/CIDR)
|
|
if 'wireguard' in data and isinstance(data['wireguard'], dict):
|
|
_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:
|
|
return jsonify({'error': f'wireguard.address is not a valid IP/CIDR: {_e}'}), 400
|
|
|
|
# Capture old identity and service configs BEFORE saving, for change detection
|
|
old_identity = dict(config_manager.configs.get('_identity', {}))
|
|
old_svc_configs = {
|
|
svc: dict(config_manager.configs.get(svc, {}))
|
|
for svc in data if svc in config_manager.service_schemas
|
|
}
|
|
if identity_updates:
|
|
stored = config_manager.configs.get('_identity', {})
|
|
stored.update(identity_updates)
|
|
config_manager.configs['_identity'] = stored
|
|
config_manager._save_all_configs()
|
|
|
|
# Map service names to their manager instances
|
|
_svc_managers = {
|
|
'network': network_manager,
|
|
'wireguard': wireguard_manager,
|
|
'email': email_manager,
|
|
'calendar': calendar_manager,
|
|
'files': file_manager,
|
|
'routing': routing_manager,
|
|
'vault': app.vault_manager,
|
|
}
|
|
|
|
all_restarted = []
|
|
all_warnings = []
|
|
|
|
# Update service configurations: persist + apply to real config files
|
|
for service, config in data.items():
|
|
if service in config_manager.service_schemas:
|
|
config_manager.update_service_config(service, config)
|
|
mgr = _svc_managers.get(service)
|
|
if mgr:
|
|
mgr.update_config(config)
|
|
result = mgr.apply_config(config)
|
|
all_restarted.extend(result.get('restarted', []))
|
|
all_warnings.extend(result.get('warnings', []))
|
|
service_bus.publish_event(EventType.CONFIG_CHANGED, service, {
|
|
'service': service,
|
|
'config': config
|
|
})
|
|
# VPN port or subnet change → all peer client configs are stale
|
|
if service == 'wireguard' and ('port' in config or 'address' in config):
|
|
for p in peer_registry.list_peers():
|
|
peer_registry.update_peer(p['peer'], {'config_needs_reinstall': True})
|
|
n = len(peer_registry.list_peers())
|
|
if n:
|
|
all_warnings.append(f'WireGuard endpoint changed — {n} peer(s) must reinstall VPN config')
|
|
# Keep identity.wireguard_port in sync with service config port
|
|
if 'port' in config:
|
|
_id = config_manager.configs.get('_identity', {})
|
|
_id['wireguard_port'] = config['port']
|
|
config_manager.configs['_identity'] = _id
|
|
config_manager._save_all_configs()
|
|
|
|
# Apply cell identity domain to network and email services
|
|
if identity_updates.get('domain'):
|
|
domain = identity_updates['domain']
|
|
net_result = network_manager.apply_domain(domain)
|
|
all_restarted.extend(net_result.get('restarted', []))
|
|
all_warnings.extend(net_result.get('warnings', []))
|
|
# Regenerate Caddyfile — virtual host names change with the domain
|
|
import ip_utils as _ip_domain
|
|
_cur_id = config_manager.configs.get('_identity', {})
|
|
_cur_range = _cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
|
_cur_name = _cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
|
_ip_domain.write_caddyfile(_cur_range, _cur_name, domain, '/app/config/caddy/Caddyfile')
|
|
|
|
# Apply cell name change to DNS hostname record
|
|
if identity_updates.get('cell_name'):
|
|
old_name = old_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
|
new_name = identity_updates['cell_name']
|
|
if old_name != new_name:
|
|
cn_result = network_manager.apply_cell_name(old_name, new_name)
|
|
all_restarted.extend(cn_result.get('restarted', []))
|
|
all_warnings.extend(cn_result.get('warnings', []))
|
|
# Regenerate Caddyfile — main virtual host name changes with cell_name
|
|
import ip_utils as _ip_name
|
|
_cur_id2 = config_manager.configs.get('_identity', {})
|
|
_cur_range2 = _cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
|
_cur_domain2 = identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
|
_ip_name.write_caddyfile(_cur_range2, new_name, _cur_domain2, '/app/config/caddy/Caddyfile')
|
|
|
|
# Apply ip_range change: regenerate DNS records, update virtual IPs + firewall rules
|
|
if identity_updates.get('ip_range'):
|
|
import ip_utils
|
|
new_range = identity_updates['ip_range']
|
|
cur_identity = config_manager.configs.get('_identity', {})
|
|
cur_cell_name = cur_identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
|
cur_domain = cur_identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
|
# Update DNS zone records immediately
|
|
ip_result = network_manager.apply_ip_range(new_range, cur_cell_name, cur_domain)
|
|
all_restarted.extend(ip_result.get('restarted', []))
|
|
all_warnings.extend(ip_result.get('warnings', []))
|
|
# Update firewall virtual IPs (iptables) and Caddy virtual IPs immediately
|
|
firewall_manager.update_service_ips(new_range)
|
|
firewall_manager.ensure_caddy_virtual_ips()
|
|
# Write new .env with updated IPs (and current ports) for next container start
|
|
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
|
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
|
|
# Regenerate Caddyfile with new VIPs
|
|
ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain,
|
|
'/app/config/caddy/Caddyfile')
|
|
# Mark ALL containers as needing restart; network_recreate signals that
|
|
# docker compose down is required before up (Docker can't change subnet in-place)
|
|
_set_pending_restart(
|
|
[f'ip_range changed to {new_range} — network will be recreated'],
|
|
['*'], network_recreate=True
|
|
)
|
|
|
|
# Detect port changes across service configs and identity
|
|
# Maps (service_key, field_name) → (port_env_key, [containers])
|
|
_PORT_CHANGE_MAP = {
|
|
('network', 'dns_port'): ('dns_port', ['dns']),
|
|
('wireguard','port'): ('wg_port', ['wireguard']),
|
|
('email', 'smtp_port'): ('mail_smtp_port', ['mail']),
|
|
('email', 'submission_port'): ('mail_submission_port', ['mail']),
|
|
('email', 'imap_port'): ('mail_imap_port', ['mail']),
|
|
('email', 'webmail_port'): ('rainloop_port', ['rainloop']),
|
|
('calendar', 'port'): ('radicale_port', ['radicale']),
|
|
('files', 'port'): ('webdav_port', ['webdav']),
|
|
('files', 'manager_port'): ('filegator_port', ['filegator']),
|
|
}
|
|
|
|
port_changed_containers = set()
|
|
port_change_messages = []
|
|
|
|
import ip_utils as _ip_utils_pcd
|
|
for (svc_key, field), (_env_key, containers) in _PORT_CHANGE_MAP.items():
|
|
if svc_key in data and field in data[svc_key]:
|
|
default_val = _ip_utils_pcd.PORT_DEFAULTS.get(_env_key)
|
|
old_val = old_svc_configs.get(svc_key, {}).get(field, default_val)
|
|
new_val = data[svc_key][field]
|
|
if old_val != new_val:
|
|
port_changed_containers.update(containers)
|
|
port_change_messages.append(
|
|
f'{svc_key} {field}: {old_val} → {new_val}'
|
|
)
|
|
|
|
# wireguard_port in identity also drives WG_PORT env var; sync to service config
|
|
if 'wireguard_port' in identity_updates:
|
|
old_wg = old_identity.get('wireguard_port', _ip_utils_pcd.PORT_DEFAULTS.get('wg_port', 51820))
|
|
new_wg = identity_updates['wireguard_port']
|
|
if old_wg != new_wg:
|
|
# Sync to wireguard service config and update wg0.conf
|
|
_wg_svc = config_manager.configs.get('wireguard', {})
|
|
_wg_svc['port'] = new_wg
|
|
config_manager.update_service_config('wireguard', _wg_svc)
|
|
wireguard_manager.update_config({'port': new_wg})
|
|
port_changed_containers.add('wireguard')
|
|
port_change_messages.append(f'wireguard_port: {old_wg} → {new_wg}')
|
|
|
|
if port_changed_containers:
|
|
import ip_utils as _ip_utils_ports
|
|
_env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
|
_ip_range = config_manager.configs.get('_identity', {}).get(
|
|
'ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')
|
|
)
|
|
_ip_utils_ports.write_env_file(
|
|
_ip_range, _env_file, _collect_service_ports(config_manager.configs)
|
|
)
|
|
_set_pending_restart(port_change_messages, list(port_changed_containers))
|
|
|
|
logger.info(f"Updated config, restarted: {all_restarted}")
|
|
return jsonify({
|
|
"message": "Configuration updated and applied",
|
|
"restarted": all_restarted,
|
|
"warnings": all_warnings,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error updating config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pending-restart helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _collect_service_ports(configs: dict) -> dict:
|
|
"""Extract current port values from service configs for .env generation."""
|
|
ports = {}
|
|
net = configs.get('network', {})
|
|
wg = configs.get('wireguard', {})
|
|
email = configs.get('email', {})
|
|
cal = configs.get('calendar', {})
|
|
files = configs.get('files', {})
|
|
identity = configs.get('_identity', {})
|
|
|
|
if 'dns_port' in net: ports['dns_port'] = net['dns_port']
|
|
if 'port' in wg: ports['wg_port'] = wg['port']
|
|
elif 'wireguard_port' in identity: ports['wg_port'] = identity['wireguard_port']
|
|
if 'smtp_port' in email: ports['mail_smtp_port'] = email['smtp_port']
|
|
if 'submission_port' in email: ports['mail_submission_port'] = email['submission_port']
|
|
if 'imap_port' in email: ports['mail_imap_port'] = email['imap_port']
|
|
if 'webmail_port' in email: ports['rainloop_port'] = email['webmail_port']
|
|
if 'port' in cal: ports['radicale_port'] = cal['port']
|
|
if 'port' in files: ports['webdav_port'] = files['port']
|
|
if 'manager_port' in files: ports['filegator_port'] = files['manager_port']
|
|
return ports
|
|
|
|
|
|
def _set_pending_restart(changes: list, containers: list = None, network_recreate: bool = False):
|
|
"""Record that specific containers need to be restarted to apply configuration.
|
|
|
|
containers: list of docker-compose service names, or None/'*' to restart all.
|
|
network_recreate: True when the Docker bridge subnet changed (requires down+up).
|
|
Merges with any existing pending state so multiple changes accumulate.
|
|
"""
|
|
from datetime import datetime as _dt
|
|
existing = config_manager.configs.get('_pending_restart', {})
|
|
existing_changes = existing.get('changes', []) if existing.get('needs_restart') else []
|
|
existing_containers = existing.get('containers', []) if existing.get('needs_restart') else []
|
|
|
|
if containers is None or '*' in (containers or []) or existing_containers == ['*']:
|
|
new_containers = ['*']
|
|
else:
|
|
new_containers = list(set(existing_containers) | set(containers))
|
|
|
|
config_manager.configs['_pending_restart'] = {
|
|
'needs_restart': True,
|
|
'changed_at': _dt.utcnow().isoformat(),
|
|
'changes': existing_changes + changes,
|
|
'containers': new_containers,
|
|
'network_recreate': network_recreate or existing.get('network_recreate', False),
|
|
}
|
|
config_manager._save_all_configs()
|
|
|
|
|
|
def _clear_pending_restart():
|
|
config_manager.configs['_pending_restart'] = {
|
|
'needs_restart': False, 'changes': [], 'containers': [], 'network_recreate': False
|
|
}
|
|
config_manager._save_all_configs()
|
|
|
|
|
|
@app.route('/api/config/pending', methods=['GET'])
|
|
def get_pending_config():
|
|
"""Return whether there are unapplied configuration changes that require a restart."""
|
|
pending = config_manager.configs.get('_pending_restart', {})
|
|
return jsonify({
|
|
'needs_restart': pending.get('needs_restart', False),
|
|
'changed_at': pending.get('changed_at'),
|
|
'changes': pending.get('changes', []),
|
|
'containers': pending.get('containers', ['*']),
|
|
})
|
|
|
|
|
|
@app.route('/api/config/pending', methods=['DELETE'])
|
|
def cancel_pending_config():
|
|
"""Discard pending configuration changes without restarting any containers."""
|
|
_clear_pending_restart()
|
|
return jsonify({'message': 'Pending changes discarded'})
|
|
|
|
|
|
@app.route('/api/config/apply', methods=['POST'])
|
|
def apply_pending_config():
|
|
"""Apply pending configuration by restarting containers via docker compose up -d."""
|
|
try:
|
|
pending = config_manager.configs.get('_pending_restart', {})
|
|
if not pending.get('needs_restart'):
|
|
return jsonify({'message': 'No pending changes to apply'})
|
|
|
|
# Get project working dir and image name from our own container labels
|
|
project_dir = '/home/roof/pic'
|
|
api_image = 'pic_api:latest' # fallback (docker-compose v1 naming)
|
|
try:
|
|
import docker as _docker_sdk
|
|
_client = _docker_sdk.from_env()
|
|
_self = _client.containers.get('cell-api')
|
|
project_dir = _self.labels.get('com.docker.compose.project.working_dir', project_dir)
|
|
# Use the actual image tag so the helper works regardless of compose version
|
|
# (docker-compose v1 builds pic_api:latest, compose v2+ builds pic-api:latest)
|
|
tags = _self.image.tags
|
|
if tags:
|
|
api_image = tags[0]
|
|
except Exception:
|
|
pass
|
|
|
|
containers = pending.get('containers', ['*'])
|
|
|
|
# Clear pending flag before we restart so it shows cleared after new containers start
|
|
_clear_pending_restart()
|
|
|
|
# Check if the IP range (network subnet) is changing — Docker cannot modify an
|
|
# existing network's subnet in-place, so we need `down` + `up` in that case.
|
|
needs_network_recreate = pending.get('network_recreate', False)
|
|
|
|
host_env = os.path.join(project_dir, '.env')
|
|
host_compose = os.path.join(project_dir, 'docker-compose.yml')
|
|
|
|
if '*' in containers:
|
|
# All-services restart: `docker compose down` or `up -d` may stop/recreate the
|
|
# API container itself, killing this background thread mid-operation.
|
|
# Spawn an independent helper container (same image as cell-api) that has docker
|
|
# CLI and survives cell-api being stopped/recreated.
|
|
if needs_network_recreate:
|
|
helper_script = (
|
|
f'sleep 2'
|
|
f' && docker compose --project-directory {project_dir}'
|
|
f' -f {host_compose} --env-file {host_env} down'
|
|
f' && docker compose --project-directory {project_dir}'
|
|
f' -f {host_compose} --env-file {host_env} up -d'
|
|
)
|
|
else:
|
|
helper_script = (
|
|
f'sleep 2'
|
|
f' && docker compose --project-directory {project_dir}'
|
|
f' -f {host_compose} --env-file {host_env} up -d'
|
|
)
|
|
|
|
def _do_apply():
|
|
import subprocess as _subprocess
|
|
_subprocess.Popen(
|
|
['docker', 'run', '--rm',
|
|
'-v', '/var/run/docker.sock:/var/run/docker.sock',
|
|
'-v', f'{project_dir}:{project_dir}',
|
|
'--entrypoint', 'sh',
|
|
api_image,
|
|
'-c', helper_script],
|
|
close_fds=True,
|
|
stdout=_subprocess.DEVNULL,
|
|
stderr=_subprocess.DEVNULL,
|
|
)
|
|
logger.info(
|
|
'spawned helper container for all-services restart'
|
|
+ (' (network_recreate)' if needs_network_recreate else '')
|
|
)
|
|
else:
|
|
# Specific containers only — API is not affected, run directly from here.
|
|
def _do_apply():
|
|
import time as _time
|
|
import subprocess as _subprocess
|
|
_time.sleep(0.3)
|
|
result = _subprocess.run(
|
|
['docker', 'compose',
|
|
'--project-directory', project_dir,
|
|
'-f', '/app/docker-compose.yml',
|
|
'--env-file', '/app/.env.compose',
|
|
'up', '-d', '--no-deps'] + containers,
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.error(f"docker compose up failed: {result.stderr.strip()}")
|
|
else:
|
|
logger.info(f'docker compose up completed for: {containers}')
|
|
|
|
threading.Thread(target=_do_apply, daemon=False).start()
|
|
|
|
return jsonify({
|
|
'message': 'Applying configuration — containers are restarting',
|
|
'restart_in_progress': True,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error applying config: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# Configuration management endpoints
|
|
@app.route('/api/config/backup', methods=['POST'])
|
|
def create_config_backup():
|
|
"""Create configuration backup."""
|
|
try:
|
|
backup_id = config_manager.backup_config()
|
|
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
|
|
'backup_id': backup_id,
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
})
|
|
return jsonify({"backup_id": backup_id})
|
|
except Exception as e:
|
|
logger.error(f"Error creating backup: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config/backups', methods=['GET'])
|
|
def list_config_backups():
|
|
"""List available backups."""
|
|
try:
|
|
backups = config_manager.list_backups()
|
|
return jsonify(backups)
|
|
except Exception as e:
|
|
logger.error(f"Error listing backups: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config/restore/<backup_id>', methods=['POST'])
|
|
def restore_config(backup_id):
|
|
"""Restore configuration from backup."""
|
|
try:
|
|
success = config_manager.restore_config(backup_id)
|
|
if success:
|
|
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
|
|
'backup_id': backup_id,
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
})
|
|
return jsonify({"message": f"Configuration restored from backup: {backup_id}"})
|
|
else:
|
|
return jsonify({"error": f"Failed to restore backup: {backup_id}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error restoring backup: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config/export', methods=['GET'])
|
|
def export_config():
|
|
"""Export configuration."""
|
|
try:
|
|
format = request.args.get('format', 'json')
|
|
config_data = config_manager.export_config(format)
|
|
return jsonify({"config": config_data, "format": format})
|
|
except Exception as e:
|
|
logger.error(f"Error exporting config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config/import', methods=['POST'])
|
|
def import_config():
|
|
"""Import configuration."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
|
|
config_data = data.get('config')
|
|
format = data.get('format', 'json')
|
|
|
|
success = config_manager.import_config(config_data, format)
|
|
if success:
|
|
return jsonify({"message": "Configuration imported successfully"})
|
|
else:
|
|
return jsonify({"error": "Failed to import configuration"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error importing config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/config/backups/<backup_id>', methods=['DELETE'])
|
|
def delete_config_backup(backup_id):
|
|
"""Delete a configuration backup."""
|
|
try:
|
|
success = config_manager.delete_backup(backup_id)
|
|
if success:
|
|
return jsonify({"message": f"Backup {backup_id} deleted"})
|
|
else:
|
|
return jsonify({"error": f"Failed to delete backup {backup_id}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error deleting backup: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Service bus endpoints
|
|
@app.route('/api/services/bus/status', methods=['GET'])
|
|
def get_service_bus_status():
|
|
"""Get service bus status."""
|
|
try:
|
|
return jsonify(service_bus.get_service_status_summary())
|
|
except Exception as e:
|
|
logger.error(f"Error getting service bus status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/services/bus/events', methods=['GET'])
|
|
def get_service_bus_events():
|
|
"""Get service bus event history."""
|
|
try:
|
|
event_type = request.args.get('type')
|
|
source = request.args.get('source')
|
|
limit = int(request.args.get('limit', 100))
|
|
|
|
events = service_bus.get_event_history(
|
|
EventType(event_type) if event_type else None,
|
|
source,
|
|
limit
|
|
)
|
|
|
|
# Convert events to serializable format
|
|
serializable_events = []
|
|
for event in events:
|
|
serializable_events.append({
|
|
'event_id': event.event_id,
|
|
'event_type': event.event_type.value,
|
|
'source': event.source,
|
|
'data': event.data,
|
|
'timestamp': event.timestamp.isoformat()
|
|
})
|
|
|
|
return jsonify(serializable_events)
|
|
except Exception as e:
|
|
logger.error(f"Error getting service bus events: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/services/bus/services/<service_name>/start', methods=['POST'])
|
|
def start_service(service_name):
|
|
"""Start a service with orchestration."""
|
|
try:
|
|
success = service_bus.orchestrate_service_start(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} started successfully"})
|
|
else:
|
|
return jsonify({"error": f"Failed to start service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error starting service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/services/bus/services/<service_name>/stop', methods=['POST'])
|
|
def stop_service(service_name):
|
|
"""Stop a service with orchestration."""
|
|
try:
|
|
success = service_bus.orchestrate_service_stop(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} stopped successfully"})
|
|
else:
|
|
return jsonify({"error": f"Failed to stop service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error stopping service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/services/bus/services/<service_name>/restart', methods=['POST'])
|
|
def restart_service(service_name):
|
|
"""Restart a service with orchestration."""
|
|
try:
|
|
success = service_bus.orchestrate_service_restart(service_name)
|
|
if success:
|
|
return jsonify({"message": f"Service {service_name} restarted successfully"})
|
|
else:
|
|
return jsonify({"error": f"Failed to restart service {service_name}"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error restarting service {service_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Logging endpoints
|
|
@app.route('/api/logs/services/<service>', methods=['GET'])
|
|
def get_service_logs(service):
|
|
"""Get logs for a specific service."""
|
|
try:
|
|
level = request.args.get('level', 'INFO')
|
|
lines = int(request.args.get('lines', 50))
|
|
|
|
logs = log_manager.get_service_logs(service, level, lines)
|
|
return jsonify({"service": service, "logs": logs})
|
|
except Exception as e:
|
|
logger.error(f"Error getting logs for {service}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/search', methods=['POST'])
|
|
def search_logs():
|
|
"""Search logs across all services."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
query = data.get('query', '')
|
|
services = data.get('services')
|
|
level = data.get('level')
|
|
time_range = data.get('time_range')
|
|
|
|
results = log_manager.search_logs(query, time_range, services, level)
|
|
return jsonify({"results": results, "count": len(results)})
|
|
except Exception as e:
|
|
logger.error(f"Error searching logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/export', methods=['POST'])
|
|
def export_logs():
|
|
"""Export logs in specified format."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
format = data.get('format', 'json')
|
|
filters = data.get('filters', {})
|
|
|
|
log_data = log_manager.export_logs(format, filters)
|
|
return jsonify({"logs": log_data, "format": format})
|
|
except Exception as e:
|
|
logger.error(f"Error exporting logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/statistics', methods=['GET'])
|
|
def get_log_statistics():
|
|
"""Get log statistics."""
|
|
try:
|
|
service = request.args.get('service')
|
|
stats = log_manager.get_log_statistics(service)
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
logger.error(f"Error getting log statistics: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/rotate', methods=['POST'])
|
|
def rotate_logs():
|
|
"""Manually rotate an API service log file."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
service = data.get('service') # None = rotate all
|
|
log_manager.rotate_logs(service)
|
|
return jsonify({"message": "Logs rotated successfully"})
|
|
except Exception as e:
|
|
logger.error(f"Error rotating logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/files', methods=['GET'])
|
|
def get_log_file_infos():
|
|
"""List service log files with sizes."""
|
|
try:
|
|
return jsonify(log_manager.get_all_log_file_infos())
|
|
except Exception as e:
|
|
logger.error(f"Error listing log files: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/verbosity', methods=['GET'])
|
|
def get_log_verbosity():
|
|
"""Return current per-service log levels."""
|
|
try:
|
|
return jsonify(log_manager.get_service_levels())
|
|
except Exception as e:
|
|
logger.error(f"Error getting log verbosity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/logs/verbosity', methods=['PUT'])
|
|
def set_log_verbosity():
|
|
"""Update log levels for one or all services. Body: {service: level} map."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
for service, level in data.items():
|
|
log_manager.set_service_level(service, level)
|
|
# Persist to config so levels survive API restarts
|
|
levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json')
|
|
os.makedirs(os.path.dirname(levels_file), exist_ok=True)
|
|
current = {}
|
|
if os.path.exists(levels_file):
|
|
try:
|
|
with open(levels_file) as f:
|
|
current = json.load(f)
|
|
except Exception:
|
|
pass
|
|
current.update(data)
|
|
with open(levels_file, 'w') as f:
|
|
json.dump(current, f, indent=2)
|
|
return jsonify({"message": "Log levels updated", "levels": log_manager.get_service_levels()})
|
|
except Exception as e:
|
|
logger.error(f"Error setting log verbosity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Network Services API
|
|
@app.route('/api/dns/records', methods=['GET'])
|
|
def get_dns_records():
|
|
"""Get DNS records."""
|
|
try:
|
|
records = network_manager.get_dns_records()
|
|
return jsonify(records)
|
|
except Exception as e:
|
|
logger.error(f"Error getting DNS records: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/dns/records', methods=['POST'])
|
|
def add_dns_record():
|
|
"""Add DNS record."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = network_manager.add_dns_record(**data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding DNS record: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/dns/records', methods=['DELETE'])
|
|
def remove_dns_record():
|
|
"""Remove DNS record."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
result = network_manager.remove_dns_record(**data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error removing DNS record: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/dhcp/leases', methods=['GET'])
|
|
def get_dhcp_leases():
|
|
"""Get DHCP leases."""
|
|
try:
|
|
leases = network_manager.get_dhcp_leases()
|
|
return jsonify(leases)
|
|
except Exception as e:
|
|
logger.error(f"Error getting DHCP leases: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/dhcp/reservations', methods=['POST'])
|
|
def add_dhcp_reservation():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if not data:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
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
|
|
|
|
@app.route('/api/dhcp/reservations', methods=['DELETE'])
|
|
def remove_dhcp_reservation():
|
|
"""Remove DHCP reservation."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
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
|
|
|
|
@app.route('/api/ntp/status', methods=['GET'])
|
|
def get_ntp_status():
|
|
"""Get NTP status."""
|
|
try:
|
|
status = network_manager.get_ntp_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting NTP status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/network/info', methods=['GET'])
|
|
def get_network_info():
|
|
"""Get general network info (interfaces, gateway, DNS, etc.)"""
|
|
try:
|
|
info = network_manager.get_network_info()
|
|
return jsonify(info)
|
|
except Exception as e:
|
|
logger.error(f"Error getting network info: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/dns/status', methods=['GET'])
|
|
def get_dns_status():
|
|
"""Get DNS service status and summary info."""
|
|
try:
|
|
status = network_manager.get_dns_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting DNS status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/network/test', methods=['POST'])
|
|
def test_network():
|
|
try:
|
|
result = network_manager.test_connectivity()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing network: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# WireGuard API
|
|
@app.route('/api/wireguard/keys', methods=['GET'])
|
|
def get_wireguard_keys():
|
|
"""Get WireGuard keys."""
|
|
try:
|
|
result = wireguard_manager.get_keys()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting WireGuard keys: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/keys/peer', methods=['POST'])
|
|
def generate_peer_keys():
|
|
"""Generate peer keys."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
name = data.get('name') or data.get('peer_name')
|
|
if not name:
|
|
return jsonify({"error": "Missing peer name"}), 400
|
|
result = wireguard_manager.generate_peer_keys(name)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error generating peer keys: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/config', methods=['GET'])
|
|
def get_wireguard_config():
|
|
"""Get WireGuard configuration."""
|
|
try:
|
|
result = wireguard_manager.get_config()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting WireGuard config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers', methods=['GET'])
|
|
def get_wireguard_peers():
|
|
"""Get WireGuard peers."""
|
|
try:
|
|
peers = wireguard_manager.get_peers()
|
|
return jsonify(peers)
|
|
except Exception as e:
|
|
logger.error(f"Error getting WireGuard peers: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers', methods=['POST'])
|
|
def add_wireguard_peer():
|
|
"""Add WireGuard peer."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
result = wireguard_manager.add_peer(
|
|
name=data.get('name', ''),
|
|
public_key=data.get('public_key', ''),
|
|
endpoint_ip=data.get('endpoint', data.get('endpoint_ip', '')),
|
|
allowed_ips=data.get('allowed_ips', ''),
|
|
persistent_keepalive=data.get('persistent_keepalive', 25)
|
|
)
|
|
return jsonify({"success": result})
|
|
except Exception as e:
|
|
logger.error(f"Error adding WireGuard peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers', methods=['DELETE'])
|
|
def remove_wireguard_peer():
|
|
"""Remove WireGuard peer."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
public_key = data.get('public_key') or data.get('name', '')
|
|
result = wireguard_manager.remove_peer(public_key)
|
|
return jsonify({"success": result})
|
|
except Exception as e:
|
|
logger.error(f"Error removing WireGuard peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/status', methods=['GET'])
|
|
def get_wireguard_status():
|
|
"""Get WireGuard status."""
|
|
try:
|
|
status = wireguard_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting WireGuard status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/connectivity', methods=['POST'])
|
|
def test_wireguard_connectivity():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = wireguard_manager.test_connectivity(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing WireGuard connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers/ip', methods=['PUT'])
|
|
def update_peer_ip():
|
|
"""Update peer IP."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
result = wireguard_manager.update_peer_ip(
|
|
data.get('public_key', data.get('peer', '')),
|
|
data.get('ip', '')
|
|
)
|
|
return jsonify({"success": result})
|
|
except Exception as e:
|
|
logger.error(f"Error updating peer IP: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers/status', methods=['POST'])
|
|
def get_peer_status():
|
|
"""Get live WireGuard status for a single peer."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
public_key = data.get('public_key', '')
|
|
if not public_key:
|
|
return jsonify({"error": "Missing public_key"}), 400
|
|
status = wireguard_manager.get_peer_status(public_key)
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting peer status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers/statuses', methods=['GET'])
|
|
def get_all_peer_statuses():
|
|
"""Get live WireGuard status for all peers (keyed by public_key)."""
|
|
try:
|
|
statuses = wireguard_manager.get_all_peer_statuses()
|
|
return jsonify(statuses)
|
|
except Exception as e:
|
|
logger.error(f"Error getting peer statuses: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/network/setup', methods=['POST'])
|
|
def setup_network():
|
|
"""Setup network configuration for internet access."""
|
|
try:
|
|
success = wireguard_manager.setup_network_configuration()
|
|
if success:
|
|
return jsonify({"message": "Network configuration setup completed successfully"})
|
|
else:
|
|
return jsonify({"error": "Failed to setup network configuration"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error setting up network configuration: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/network/status', methods=['GET'])
|
|
def get_network_status():
|
|
"""Get network configuration status."""
|
|
try:
|
|
status = wireguard_manager.get_network_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting network status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/peers/config', methods=['POST'])
|
|
def get_peer_config():
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
peer_name = data.get('name', data.get('peer', ''))
|
|
|
|
# Look up peer details from registry if not supplied
|
|
peer_ip = data.get('ip', '')
|
|
peer_private_key = data.get('private_key', '')
|
|
registered = peer_registry.get_peer(peer_name) if peer_name else {}
|
|
if peer_name and (not peer_ip or not peer_private_key):
|
|
if registered:
|
|
peer_ip = peer_ip or registered.get('ip', '')
|
|
peer_private_key = peer_private_key or registered.get('private_key', '')
|
|
|
|
# Use real external endpoint if not supplied
|
|
server_endpoint = data.get('server_endpoint', '')
|
|
if not server_endpoint:
|
|
srv = wireguard_manager.get_server_config()
|
|
server_endpoint = srv.get('endpoint') or '<SERVER_IP>'
|
|
|
|
# Determine AllowedIPs: explicit > peer's stored internet_access > default full tunnel
|
|
allowed_ips = data.get('allowed_ips') or None
|
|
if not allowed_ips and registered:
|
|
internet_access = registered.get('internet_access', True)
|
|
allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips()
|
|
|
|
result = wireguard_manager.get_peer_config(
|
|
peer_name=peer_name,
|
|
peer_ip=peer_ip,
|
|
peer_private_key=peer_private_key,
|
|
server_endpoint=server_endpoint,
|
|
allowed_ips=allowed_ips,
|
|
)
|
|
return jsonify({"config": result})
|
|
except Exception as e:
|
|
logger.error(f"Error getting peer config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/server-config', methods=['GET'])
|
|
def get_server_config():
|
|
try:
|
|
config = wireguard_manager.get_server_config()
|
|
return jsonify(config)
|
|
except Exception as e:
|
|
logger.error(f"Error getting server config: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/refresh-ip', methods=['POST'])
|
|
def refresh_external_ip():
|
|
try:
|
|
ip = wireguard_manager.get_external_ip(force_refresh=True)
|
|
port = wireguard_manager._get_configured_port()
|
|
return jsonify({
|
|
'external_ip': ip,
|
|
'port': port,
|
|
'endpoint': f'{ip}:{port}' if ip else None,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error refreshing external IP: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/apply-enforcement', methods=['POST'])
|
|
def apply_wireguard_enforcement():
|
|
"""Re-apply per-peer iptables and DNS enforcement rules (call after WireGuard restart)."""
|
|
try:
|
|
peers = peer_registry.list_peers()
|
|
firewall_manager.apply_all_peer_rules(peers)
|
|
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain())
|
|
return jsonify({'ok': True, 'peers': len(peers)})
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/wireguard/check-port', methods=['POST'])
|
|
def check_wireguard_port():
|
|
try:
|
|
port_open = wireguard_manager.check_port_open()
|
|
return jsonify({'port_open': port_open, 'port': wireguard_manager._get_configured_port()})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# ── Cell-to-cell connections ─────────────────────────────────────────────────
|
|
|
|
@app.route('/api/cells/invite', methods=['GET'])
|
|
def get_cell_invite():
|
|
"""Generate an invite package for this cell."""
|
|
try:
|
|
identity = config_manager.configs.get('_identity', {})
|
|
cell_name = identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell'))
|
|
domain = identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
|
invite = cell_link_manager.generate_invite(cell_name, domain)
|
|
return jsonify(invite)
|
|
except Exception as e:
|
|
logger.error(f"Error generating cell invite: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/cells', methods=['GET'])
|
|
def list_cell_connections():
|
|
"""List all connected cells."""
|
|
try:
|
|
return jsonify(cell_link_manager.list_connections())
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/cells', methods=['POST'])
|
|
def add_cell_connection():
|
|
"""Connect to a remote cell using their invite package."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
for field in ('cell_name', 'public_key', 'vpn_subnet', 'dns_ip', 'domain'):
|
|
if field not in data:
|
|
return jsonify({'error': f'Missing field: {field}'}), 400
|
|
link = cell_link_manager.add_connection(data)
|
|
return jsonify({'message': f"Connected to cell '{data['cell_name']}'", 'link': link}), 201
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
except Exception as e:
|
|
logger.error(f"Error adding cell connection: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/cells/<cell_name>', methods=['DELETE'])
|
|
def remove_cell_connection(cell_name):
|
|
"""Disconnect from a remote cell."""
|
|
try:
|
|
cell_link_manager.remove_connection(cell_name)
|
|
return jsonify({'message': f"Cell '{cell_name}' disconnected"})
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 404
|
|
except Exception as e:
|
|
logger.error(f"Error removing cell connection: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/cells/<cell_name>/status', methods=['GET'])
|
|
def get_cell_connection_status(cell_name):
|
|
"""Get live status for a connected cell."""
|
|
try:
|
|
status = cell_link_manager.get_connection_status(cell_name)
|
|
return jsonify(status)
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 404
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
# Peer Registry API
|
|
@app.route('/api/peers', methods=['GET'])
|
|
def get_peers():
|
|
"""Get all peers."""
|
|
try:
|
|
peers = peer_registry.list_peers()
|
|
return jsonify(peers)
|
|
except Exception as e:
|
|
logger.error(f"Error getting peers: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
def _next_peer_ip() -> str:
|
|
"""Auto-assign the next free host address from the configured VPN subnet."""
|
|
import ipaddress
|
|
server_addr = wireguard_manager._get_configured_address() # e.g. '10.0.0.1/24'
|
|
network = ipaddress.ip_network(server_addr, strict=False)
|
|
server_ip = str(ipaddress.ip_interface(server_addr).ip)
|
|
used = {p.get('ip', '').split('/')[0] for p in peer_registry.list_peers()}
|
|
for host in network.hosts():
|
|
ip = str(host)
|
|
if ip == server_ip:
|
|
continue
|
|
if ip not in used:
|
|
return ip
|
|
raise ValueError(f'No free IPs left in {network}')
|
|
|
|
|
|
@app.route('/api/peers', methods=['POST'])
|
|
def add_peer():
|
|
"""Add a peer."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
|
|
# Validate required fields (ip is optional — auto-assigned if omitted)
|
|
required_fields = ['name', 'public_key']
|
|
for field in required_fields:
|
|
if field not in data:
|
|
return jsonify({"error": f"Missing required field: {field}"}), 400
|
|
|
|
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'],
|
|
'ip': assigned_ip,
|
|
'public_key': data['public_key'],
|
|
'private_key': data.get('private_key'),
|
|
'server_public_key': data.get('server_public_key'),
|
|
'server_endpoint': data.get('server_endpoint'),
|
|
'allowed_ips': data.get('allowed_ips'),
|
|
'persistent_keepalive': data.get('persistent_keepalive'),
|
|
'description': data.get('description'),
|
|
'internet_access': data.get('internet_access', True),
|
|
'service_access': service_access,
|
|
'peer_access': data.get('peer_access', True),
|
|
'config_needs_reinstall': False,
|
|
}
|
|
|
|
success = peer_registry.add_peer(peer_info)
|
|
if success:
|
|
# Apply server-side enforcement immediately
|
|
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info)
|
|
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain())
|
|
return jsonify({"message": f"Peer {data['name']} added successfully", "ip": assigned_ip}), 201
|
|
else:
|
|
return jsonify({"error": f"Peer {data['name']} already exists"}), 400
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route('/api/peers/<peer_name>', methods=['PUT'])
|
|
def update_peer(peer_name):
|
|
"""Update peer settings. Marks config_needs_reinstall if VPN config changed."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
existing = peer_registry.get_peer(peer_name)
|
|
if not existing:
|
|
return jsonify({"error": "Peer not found"}), 404
|
|
|
|
# Detect changes that require client to reinstall tunnel config
|
|
config_changed = (
|
|
('internet_access' in data and data['internet_access'] != existing.get('internet_access', True)) or
|
|
('ip' in data and data['ip'] != existing.get('ip')) or
|
|
('persistent_keepalive' in data and data['persistent_keepalive'] != existing.get('persistent_keepalive'))
|
|
)
|
|
|
|
updates = {k: v for k, v in data.items()}
|
|
if config_changed:
|
|
updates['config_needs_reinstall'] = True
|
|
|
|
success = peer_registry.update_peer(peer_name, updates)
|
|
if success:
|
|
# Re-apply server-side enforcement with updated settings
|
|
updated_peer = peer_registry.get_peer(peer_name)
|
|
if updated_peer:
|
|
firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer)
|
|
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain())
|
|
result = {"message": f"Peer {peer_name} updated", "config_changed": config_changed}
|
|
return jsonify(result)
|
|
else:
|
|
return jsonify({"error": "Update failed"}), 500
|
|
except Exception as e:
|
|
logger.error(f"Error updating peer {peer_name}: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route('/api/peers/<peer_name>/clear-reinstall', methods=['POST'])
|
|
def clear_peer_reinstall(peer_name):
|
|
"""Clear the config_needs_reinstall flag once user has downloaded new config."""
|
|
try:
|
|
peer_registry.clear_reinstall_flag(peer_name)
|
|
return jsonify({"message": "Reinstall flag cleared"})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route('/api/peers/<peer_name>', methods=['DELETE'])
|
|
def remove_peer(peer_name):
|
|
"""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"})
|
|
except Exception as e:
|
|
logger.error(f"Error removing peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/peers/register', methods=['POST'])
|
|
def register_peer():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = peer_registry.register_peer(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error registering peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/peers/<peer_name>/unregister', methods=['DELETE'])
|
|
def unregister_peer(peer_name):
|
|
"""Unregister a peer."""
|
|
try:
|
|
result = peer_registry.unregister_peer(peer_name)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error unregistering peer: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/peers/<peer_name>/update-ip', methods=['PUT'])
|
|
def update_peer_ip_registry(peer_name):
|
|
"""Update peer IP."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
new_ip = data.get('ip') if data else None
|
|
if not new_ip:
|
|
return jsonify({"error": "Missing ip"}), 400
|
|
success = peer_registry.update_peer_ip(peer_name, new_ip)
|
|
if success:
|
|
# Update routing and WireGuard configs
|
|
try:
|
|
routing_manager.update_peer_ip(peer_name, new_ip)
|
|
except Exception as e:
|
|
logger.warning(f"RoutingManager update_peer_ip failed: {e}")
|
|
try:
|
|
# For now, skip WireGuard update - method not implemented
|
|
logger.warning(f"WireGuardManager update_peer_ip not implemented yet")
|
|
except Exception as e:
|
|
logger.warning(f"WireGuardManager update_peer_ip failed: {e}")
|
|
return jsonify({"message": f"IP update received for {peer_name}"})
|
|
else:
|
|
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
|
except Exception as e:
|
|
logger.error(f"Error updating peer IP: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/ip-update', methods=['POST'])
|
|
def ip_update():
|
|
"""Handle IP update from peer."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
peer_name = data.get('peer')
|
|
new_ip = data.get('ip')
|
|
if not peer_name or not new_ip:
|
|
return jsonify({"error": "Missing peer or ip"}), 400
|
|
success = peer_registry.update_peer_ip(peer_name, new_ip)
|
|
if success:
|
|
# Update routing and WireGuard configs
|
|
try:
|
|
routing_manager.update_peer_ip(peer_name, new_ip)
|
|
except Exception as e:
|
|
logger.warning(f"RoutingManager update_peer_ip failed: {e}")
|
|
try:
|
|
# For now, skip WireGuard update - method not implemented
|
|
logger.warning(f"WireGuardManager update_peer_ip not implemented yet")
|
|
except Exception as e:
|
|
logger.warning(f"WireGuardManager update_peer_ip failed: {e}")
|
|
return jsonify({"message": f"IP update received for {peer_name}"})
|
|
else:
|
|
return jsonify({"error": f"Peer {peer_name} not found"}), 404
|
|
except Exception as e:
|
|
logger.error(f"Error handling IP update: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Email Services API
|
|
@app.route('/api/email/users', methods=['GET'])
|
|
def get_email_users():
|
|
"""Get email users."""
|
|
try:
|
|
users = email_manager.get_users()
|
|
return jsonify(users)
|
|
except Exception as e:
|
|
logger.error(f"Error getting email users: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/users', methods=['POST'])
|
|
def create_email_user():
|
|
"""Create email user."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = email_manager.create_user(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error creating email user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/users/<username>', methods=['DELETE'])
|
|
def delete_email_user(username):
|
|
"""Delete email user."""
|
|
try:
|
|
result = email_manager.delete_user(username)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error deleting email user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/status', methods=['GET'])
|
|
def get_email_status():
|
|
"""Get email service status."""
|
|
try:
|
|
status = email_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting email status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/connectivity', methods=['GET'])
|
|
def test_email_connectivity():
|
|
"""Test email connectivity."""
|
|
try:
|
|
result = email_manager.test_connectivity()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing email connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/send', methods=['POST'])
|
|
def send_email():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = email_manager.send_email(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error sending email: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/email/mailbox/<username>', methods=['GET'])
|
|
def get_mailbox_info(username):
|
|
"""Get mailbox information."""
|
|
try:
|
|
result = email_manager.get_mailbox_info(username)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting mailbox info: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Calendar Services API
|
|
@app.route('/api/calendar/users', methods=['GET'])
|
|
def get_calendar_users():
|
|
"""Get calendar users."""
|
|
try:
|
|
users = calendar_manager.get_users()
|
|
return jsonify(users)
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar users: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/users', methods=['POST'])
|
|
def create_calendar_user():
|
|
"""Create calendar user."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = calendar_manager.create_user(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error creating calendar user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/users/<username>', methods=['DELETE'])
|
|
def delete_calendar_user(username):
|
|
"""Delete calendar user."""
|
|
try:
|
|
result = calendar_manager.delete_user(username)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error deleting calendar user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/calendars', methods=['POST'])
|
|
def create_calendar():
|
|
"""Create calendar."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = calendar_manager.create_calendar(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error creating calendar: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/events', methods=['POST'])
|
|
def add_calendar_event():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = calendar_manager.add_event(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding calendar event: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/events/<username>/<calendar_name>', methods=['GET'])
|
|
def get_calendar_events(username, calendar_name):
|
|
"""Get calendar events."""
|
|
try:
|
|
params = request.args.to_dict()
|
|
result = calendar_manager.get_events(username, calendar_name, params)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar events: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/status', methods=['GET'])
|
|
def get_calendar_status():
|
|
"""Get calendar service status."""
|
|
try:
|
|
status = calendar_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/calendar/connectivity', methods=['GET'])
|
|
def test_calendar_connectivity():
|
|
"""Test calendar connectivity."""
|
|
try:
|
|
result = calendar_manager.test_connectivity()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing calendar connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# File Services API
|
|
@app.route('/api/files/users', methods=['GET'])
|
|
def get_file_users():
|
|
"""Get file storage users."""
|
|
try:
|
|
users = file_manager.get_users()
|
|
return jsonify(users)
|
|
except Exception as e:
|
|
logger.error(f"Error getting file users: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/users', methods=['POST'])
|
|
def create_file_user():
|
|
"""Create file storage user."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = file_manager.create_user(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error creating file user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/users/<username>', methods=['DELETE'])
|
|
def delete_file_user(username):
|
|
"""Delete file storage user."""
|
|
try:
|
|
result = file_manager.delete_user(username)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error deleting file user: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/folders', methods=['POST'])
|
|
def create_folder():
|
|
"""Create folder."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = file_manager.create_folder(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error creating folder: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/folders/<username>/<path:folder_path>', methods=['DELETE'])
|
|
def delete_folder(username, folder_path):
|
|
"""Delete folder."""
|
|
try:
|
|
result = file_manager.delete_folder(username, folder_path)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error deleting folder: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/upload/<username>', methods=['POST'])
|
|
def upload_file(username):
|
|
"""Upload file."""
|
|
try:
|
|
if 'file' not in request.files:
|
|
return jsonify({"error": "No file provided"}), 400
|
|
|
|
file = request.files['file']
|
|
path = request.form.get('path', '')
|
|
|
|
result = file_manager.upload_file(username, file, path)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error uploading file: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/download/<username>/<path:file_path>', methods=['GET'])
|
|
def download_file(username, file_path):
|
|
"""Download file."""
|
|
try:
|
|
result = file_manager.download_file(username, file_path)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error downloading file: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/delete/<username>/<path:file_path>', methods=['DELETE'])
|
|
def delete_file(username, file_path):
|
|
"""Delete file."""
|
|
try:
|
|
result = file_manager.delete_file(username, file_path)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error deleting file: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/list/<username>', methods=['GET'])
|
|
def list_files(username):
|
|
"""List files."""
|
|
try:
|
|
folder = request.args.get('folder', '')
|
|
result = file_manager.list_files(username, folder)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error listing files: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/status', methods=['GET'])
|
|
def get_file_status():
|
|
"""Get file service status."""
|
|
try:
|
|
status = file_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting file status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/files/connectivity', methods=['GET'])
|
|
def test_file_connectivity():
|
|
"""Test file service connectivity."""
|
|
try:
|
|
result = file_manager.test_connectivity()
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing file connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Routing API
|
|
@app.route('/api/routing/status', methods=['GET'])
|
|
def get_routing_status():
|
|
"""Get routing status."""
|
|
try:
|
|
status = routing_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting routing status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/setup', methods=['POST'])
|
|
def setup_routing():
|
|
"""Apply/verify routing setup (WireGuard handles NAT via PostUp rules)."""
|
|
try:
|
|
status = routing_manager.get_status()
|
|
return jsonify({'success': True, 'message': 'Routing managed by WireGuard PostUp rules', **status})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/nat', methods=['POST'])
|
|
def add_nat_rule():
|
|
"""Add NAT rule.
|
|
JSON fields:
|
|
- source_network (CIDR)
|
|
- target_interface (str)
|
|
- masquerade (bool, default True)
|
|
- nat_type (MASQUERADE, SNAT, DNAT)
|
|
- protocol (TCP, UDP, ALL)
|
|
- external_port (str, optional)
|
|
- internal_ip (str, optional)
|
|
- internal_port (str, optional)
|
|
"""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
result = routing_manager.add_nat_rule(
|
|
source_network=data.get('source_network'),
|
|
target_interface=data.get('target_interface'),
|
|
masquerade=data.get('masquerade', True),
|
|
nat_type=data.get('nat_type', 'MASQUERADE'),
|
|
protocol=data.get('protocol', 'ALL'),
|
|
external_port=data.get('external_port'),
|
|
internal_ip=data.get('internal_ip'),
|
|
internal_port=data.get('internal_port')
|
|
)
|
|
return jsonify({'success': result})
|
|
except Exception as e:
|
|
logger.error(f"Error adding NAT rule: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/nat/<rule_id>', methods=['DELETE'])
|
|
def remove_nat_rule(rule_id):
|
|
"""Remove NAT rule."""
|
|
try:
|
|
result = routing_manager.remove_nat_rule(rule_id)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error removing NAT rule: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/peers', methods=['POST'])
|
|
def add_peer_route():
|
|
"""Add peer route."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
result = routing_manager.add_peer_route(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding peer route: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/peers/<peer_name>', methods=['DELETE'])
|
|
def remove_peer_route(peer_name):
|
|
"""Remove peer route."""
|
|
try:
|
|
result = routing_manager.remove_peer_route(peer_name)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error removing peer route: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/exit-nodes', methods=['POST'])
|
|
def add_exit_node():
|
|
"""Add exit node."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
result = routing_manager.add_exit_node(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding exit node: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/bridge', methods=['POST'])
|
|
def add_bridge_route():
|
|
"""Add bridge route."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
result = routing_manager.add_bridge_route(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding bridge route: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/split', methods=['POST'])
|
|
def add_split_route():
|
|
"""Add split route."""
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
result = routing_manager.add_split_route(data)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error adding split route: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/firewall', methods=['POST'])
|
|
def add_firewall_rule():
|
|
"""Add firewall rule.
|
|
JSON fields:
|
|
- rule_type (INPUT, OUTPUT, FORWARD)
|
|
- source (CIDR)
|
|
- destination (CIDR)
|
|
- action (ACCEPT, DROP, REJECT)
|
|
- protocol (TCP, UDP, ICMP, ALL)
|
|
- port (str, optional)
|
|
- port_range (str, optional, e.g. '1000-2000')
|
|
"""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
result = routing_manager.add_firewall_rule(
|
|
rule_type=data.get('rule_type'),
|
|
source=data.get('source'),
|
|
destination=data.get('destination'),
|
|
action=data.get('action', 'ACCEPT'),
|
|
port=data.get('port'),
|
|
protocol=data.get('protocol', 'ALL'),
|
|
port_range=data.get('port_range')
|
|
)
|
|
return jsonify({'success': result})
|
|
except Exception as e:
|
|
logger.error(f"Error adding firewall rule: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/firewall/<rule_id>', methods=['DELETE'])
|
|
def remove_firewall_rule(rule_id):
|
|
try:
|
|
result = routing_manager.remove_firewall_rule(rule_id)
|
|
return jsonify({'success': result}), (200 if result else 404)
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/routing/live-iptables', methods=['GET'])
|
|
def get_live_iptables():
|
|
try:
|
|
return jsonify(routing_manager.get_live_iptables())
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/routing/connectivity', methods=['POST'])
|
|
def test_routing_connectivity():
|
|
"""Test routing connectivity."""
|
|
try:
|
|
data = request.get_json(silent=True) or {}
|
|
target_ip = data.get('target_ip', '8.8.8.8')
|
|
via_peer = data.get('via_peer')
|
|
result = routing_manager.test_routing_connectivity(target_ip, via_peer)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error testing routing connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/logs', methods=['GET'])
|
|
def get_routing_logs():
|
|
"""Get routing logs."""
|
|
try:
|
|
lines = request.args.get('lines', 50, type=int)
|
|
result = routing_manager.get_logs(lines)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error getting routing logs: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/nat', methods=['GET'])
|
|
def get_nat_rules():
|
|
"""Get all NAT rules."""
|
|
try:
|
|
rules = routing_manager.get_nat_rules()
|
|
return jsonify({"nat_rules": rules})
|
|
except Exception as e:
|
|
logger.error(f"Error getting NAT rules: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/peers', methods=['GET'])
|
|
def get_peer_routes():
|
|
"""Get all peer routes."""
|
|
try:
|
|
routes = routing_manager.get_peer_routes()
|
|
return jsonify({"peer_routes": routes})
|
|
except Exception as e:
|
|
logger.error(f"Error getting peer routes: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/routing/firewall', methods=['GET'])
|
|
def get_firewall_rules():
|
|
"""Get all firewall rules."""
|
|
try:
|
|
rules = routing_manager.get_firewall_rules()
|
|
return jsonify({"firewall_rules": rules})
|
|
except Exception as e:
|
|
logger.error(f"Error getting firewall rules: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Vault & Trust API (Phase 6)
|
|
@app.route('/api/vault/status', methods=['GET'])
|
|
def get_vault_status():
|
|
"""Get vault status."""
|
|
try:
|
|
status = current_app.vault_manager.get_status()
|
|
return jsonify(status)
|
|
except Exception as e:
|
|
logger.error(f"Error getting vault status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/certificates', methods=['GET'])
|
|
def get_certificates():
|
|
"""Get all certificates."""
|
|
try:
|
|
certificates = current_app.vault_manager.list_certificates()
|
|
return jsonify(certificates)
|
|
except Exception as e:
|
|
logger.error(f"Error getting certificates: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/certificates', methods=['POST'])
|
|
def generate_certificate():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = current_app.vault_manager.generate_certificate(
|
|
common_name=data['common_name'],
|
|
domains=data.get('domains', []),
|
|
key_size=data.get('key_size', 2048),
|
|
days=data.get('days', 365)
|
|
)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
logger.error(f"Error generating certificate: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/certificates/<common_name>', methods=['DELETE'])
|
|
def revoke_certificate(common_name):
|
|
"""Revoke certificate."""
|
|
try:
|
|
result = current_app.vault_manager.revoke_certificate(common_name)
|
|
return jsonify({"revoked": result})
|
|
except Exception as e:
|
|
logger.error(f"Error revoking certificate: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/ca/certificate', methods=['GET'])
|
|
def get_ca_certificate():
|
|
"""Get CA certificate."""
|
|
try:
|
|
cert = current_app.vault_manager.get_ca_certificate()
|
|
return jsonify({"certificate": cert})
|
|
except Exception as e:
|
|
logger.error(f"Error getting CA certificate: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/age/public-key', methods=['GET'])
|
|
def get_age_public_key():
|
|
"""Get Age public key."""
|
|
try:
|
|
key = current_app.vault_manager.get_age_public_key()
|
|
return jsonify({"public_key": key})
|
|
except Exception as e:
|
|
logger.error(f"Error getting Age public key: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/trust/keys', methods=['GET'])
|
|
def get_trusted_keys():
|
|
"""Get trusted keys."""
|
|
try:
|
|
keys = current_app.vault_manager.get_trusted_keys()
|
|
return jsonify(keys)
|
|
except Exception as e:
|
|
logger.error(f"Error getting trusted keys: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/trust/keys', methods=['POST'])
|
|
def add_trusted_key():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = current_app.vault_manager.add_trusted_key(
|
|
name=data['name'],
|
|
public_key=data['public_key'],
|
|
trust_level=data.get('trust_level', 'direct')
|
|
)
|
|
return jsonify({"added": result})
|
|
except Exception as e:
|
|
logger.error(f"Error adding trusted key: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/trust/keys/<name>', methods=['DELETE'])
|
|
def remove_trusted_key(name):
|
|
"""Remove trusted key."""
|
|
try:
|
|
result = current_app.vault_manager.remove_trusted_key(name)
|
|
return jsonify({"removed": result})
|
|
except Exception as e:
|
|
logger.error(f"Error removing trusted key: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/trust/verify', methods=['POST'])
|
|
def verify_trust_chain():
|
|
try:
|
|
data = request.get_json(silent=True)
|
|
if data is None:
|
|
return jsonify({"error": "No data provided"}), 400
|
|
result = current_app.vault_manager.verify_trust_chain(
|
|
peer_name=data['peer_name'],
|
|
signature=data['signature'],
|
|
data=data['data']
|
|
)
|
|
return jsonify({"verified": result})
|
|
except Exception as e:
|
|
logger.error(f"Error verifying trust chain: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/vault/trust/chains', methods=['GET'])
|
|
def get_trust_chains():
|
|
"""Get trust chains."""
|
|
try:
|
|
chains = current_app.vault_manager.get_trust_chains()
|
|
return jsonify(chains)
|
|
except Exception as e:
|
|
logger.error(f"Error getting trust chains: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
# Services API
|
|
@app.route('/api/services/status', methods=['GET'])
|
|
def get_all_services_status():
|
|
"""Get status of all services."""
|
|
try:
|
|
# Use service bus to get status from all services
|
|
services_status = {}
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
status = service.get_status()
|
|
|
|
# Clean up status for UI consumption
|
|
if isinstance(status, dict):
|
|
# Extract core status information
|
|
clean_status = {
|
|
'status': status.get('status', 'unknown'),
|
|
'running': status.get('running', False),
|
|
'timestamp': status.get('timestamp', datetime.utcnow().isoformat())
|
|
}
|
|
|
|
# Add service-specific metrics
|
|
if service_name == 'network':
|
|
clean_status.update({
|
|
'dns_status': status.get('dns_running', False),
|
|
'dhcp_status': status.get('dhcp_running', False),
|
|
'ntp_status': status.get('ntp_running', False)
|
|
})
|
|
elif service_name == 'wireguard':
|
|
clean_status.update({
|
|
'peers_count': status.get('peers_count', 0),
|
|
'interface': status.get('interface', 'unknown')
|
|
})
|
|
elif service_name == 'email':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'domain': status.get('domain', 'unknown')
|
|
})
|
|
elif service_name == 'calendar':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'calendars_count': status.get('calendars_count', 0)
|
|
})
|
|
elif service_name == 'files':
|
|
clean_status.update({
|
|
'users_count': status.get('users_count', 0),
|
|
'storage_used': status.get('total_storage_used', {})
|
|
})
|
|
elif service_name == 'routing':
|
|
clean_status.update({
|
|
'nat_rules_count': status.get('nat_rules_count', 0),
|
|
'peer_routes_count': status.get('peer_routes_count', 0),
|
|
'firewall_rules_count': status.get('firewall_rules_count', 0)
|
|
})
|
|
elif service_name == 'vault':
|
|
clean_status.update({
|
|
'certificates_count': status.get('certificates_count', 0),
|
|
'trusted_keys_count': status.get('trusted_keys_count', 0)
|
|
})
|
|
|
|
services_status[service_name] = clean_status
|
|
else:
|
|
services_status[service_name] = {'status': str(status), 'running': bool(status)}
|
|
|
|
except Exception as e:
|
|
services_status[service_name] = {'error': str(e), 'status': 'offline', 'running': False}
|
|
|
|
return jsonify({
|
|
"network": services_status.get('network', {}),
|
|
"wireguard": services_status.get('wireguard', {}),
|
|
"email": services_status.get('email', {}),
|
|
"calendar": services_status.get('calendar', {}),
|
|
"files": services_status.get('files', {}),
|
|
"routing": services_status.get('routing', {}),
|
|
"vault": services_status.get('vault', {}),
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error getting all services status: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/services/connectivity', methods=['GET'])
|
|
def test_all_services_connectivity():
|
|
"""Test connectivity of all services."""
|
|
try:
|
|
# Use service bus to test connectivity
|
|
connectivity_results = {}
|
|
for service_name in service_bus.list_services():
|
|
try:
|
|
service = service_bus.get_service(service_name)
|
|
if hasattr(service, 'test_connectivity'):
|
|
connectivity_results[service_name] = service.test_connectivity()
|
|
else:
|
|
connectivity_results[service_name] = {'status': 'ok', 'message': 'No connectivity test available'}
|
|
except Exception as e:
|
|
connectivity_results[service_name] = {'status': 'error', 'message': str(e)}
|
|
|
|
return jsonify({
|
|
"network": connectivity_results.get('network', {}),
|
|
"wireguard": connectivity_results.get('wireguard', {}),
|
|
"email": connectivity_results.get('email', {}),
|
|
"calendar": connectivity_results.get('calendar', {}),
|
|
"files": connectivity_results.get('files', {}),
|
|
"routing": connectivity_results.get('routing', {}),
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error testing all services connectivity: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/health/history', methods=['GET'])
|
|
def get_health_history():
|
|
"""Get recent unified health check results."""
|
|
return jsonify(list(health_history))
|
|
|
|
@app.route('/api/health/history/clear', methods=['POST'])
|
|
def clear_health_history():
|
|
"""Clear health history and reset alert counters."""
|
|
global service_alert_counters
|
|
health_history.clear()
|
|
service_alert_counters = {}
|
|
return jsonify({'message': 'Health history cleared'})
|
|
|
|
@app.route('/api/logs', methods=['GET'])
|
|
def get_backend_logs():
|
|
"""Get backend log file contents (last N lines)."""
|
|
log_file = os.path.join(os.path.dirname(__file__), 'picell.log')
|
|
lines = int(request.args.get('lines', 100))
|
|
try:
|
|
if not os.path.exists(log_file):
|
|
return jsonify({"error": "Log file not found."}), 404
|
|
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
all_lines = f.readlines()
|
|
tail_lines = all_lines[-lines:] if lines > 0 else all_lines
|
|
return jsonify({"log": ''.join(tail_lines)})
|
|
except Exception as e:
|
|
logger.error(f"Error reading log file: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
@app.route('/api/containers', methods=['GET'])
|
|
def list_containers():
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
try:
|
|
containers = container_manager.list_containers()
|
|
return jsonify(containers)
|
|
except Exception as e:
|
|
logger.error(f"Error listing containers: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/containers/<name>/start', methods=['POST'])
|
|
def start_container(name):
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
try:
|
|
success = container_manager.start_container(name)
|
|
return jsonify({'started': success})
|
|
except Exception as e:
|
|
logger.error(f"Error starting container {name}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/containers/<name>/stop', methods=['POST'])
|
|
def stop_container(name):
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
try:
|
|
success = container_manager.stop_container(name)
|
|
return jsonify({'stopped': success})
|
|
except Exception as e:
|
|
logger.error(f"Error stopping container {name}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/containers/<name>/restart', methods=['POST'])
|
|
def restart_container(name):
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
try:
|
|
success = container_manager.restart_container(name)
|
|
return jsonify({'restarted': success})
|
|
except Exception as e:
|
|
logger.error(f"Error restarting container {name}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@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
|
|
tail = request.args.get('tail', default=100, type=int)
|
|
try:
|
|
logs = container_manager.get_container_logs(name, tail=tail)
|
|
return jsonify({'logs': logs})
|
|
except Exception as e:
|
|
logger.error(f"Error getting logs for container {name}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@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
|
|
try:
|
|
stats = container_manager.get_container_stats(name)
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
logger.error(f"Error getting stats for container {name}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@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
|
|
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
|
|
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
|
|
app.vault_manager.store_secret(data['name'], data['value'])
|
|
return jsonify({'stored': True})
|
|
|
|
@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
|
|
value = app.vault_manager.get_secret(name)
|
|
if value is None:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify({'name': name, 'value': value})
|
|
|
|
@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
|
|
result = app.vault_manager.delete_secret(name)
|
|
return jsonify({'deleted': result})
|
|
|
|
# Enhance container creation to support secrets
|
|
@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
|
|
data = request.get_json(silent=True)
|
|
if not data or 'image' not in data:
|
|
return jsonify({'error': 'Missing image parameter'}), 400
|
|
name = data.get('name', '')
|
|
env = data.get('env', {})
|
|
# If 'secrets' is provided, resolve secret values and add to env
|
|
secrets = data.get('secrets', [])
|
|
if secrets:
|
|
for secret_name in secrets:
|
|
secret_value = app.vault_manager.get_secret(secret_name)
|
|
if secret_value is not None:
|
|
env[secret_name] = secret_value
|
|
volumes = data.get('volumes', {})
|
|
command = data.get('command', '')
|
|
ports = data.get('ports', {})
|
|
result = container_manager.create_container(
|
|
image=data['image'],
|
|
name=name,
|
|
env=env,
|
|
volumes=volumes,
|
|
command=command,
|
|
ports=ports
|
|
)
|
|
if 'error' in result:
|
|
return jsonify(result), 500
|
|
return jsonify(result)
|
|
|
|
@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
|
|
force = request.args.get('force', default=False, type=bool)
|
|
success = container_manager.remove_container(name, force=force)
|
|
return jsonify({'removed': success})
|
|
|
|
@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
|
|
images = container_manager.list_images()
|
|
return jsonify(images)
|
|
|
|
@app.route('/api/images/pull', methods=['POST'])
|
|
def pull_image():
|
|
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
|
|
result = container_manager.pull_image(data['image'])
|
|
if 'error' in result:
|
|
return jsonify(result), 500
|
|
return jsonify(result)
|
|
|
|
@app.route('/api/images/<image>', methods=['DELETE'])
|
|
def remove_image(image):
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
force = request.args.get('force', default=False, type=bool)
|
|
success = container_manager.remove_image(image, force=force)
|
|
return jsonify({'removed': success})
|
|
|
|
@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
|
|
volumes = container_manager.list_volumes()
|
|
return jsonify(volumes)
|
|
|
|
@app.route('/api/volumes', methods=['POST'])
|
|
def create_volume():
|
|
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:
|
|
return jsonify({'error': 'Missing name parameter'}), 400
|
|
result = container_manager.create_volume(data['name'])
|
|
if 'error' in result:
|
|
return jsonify(result), 500
|
|
return jsonify(result)
|
|
|
|
@app.route('/api/volumes/<name>', methods=['DELETE'])
|
|
def remove_volume(name):
|
|
if not is_local_request():
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
force = request.args.get('force', default=False, type=bool)
|
|
success = container_manager.remove_volume(name, force=force)
|
|
return jsonify({'removed': success})
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=3000, debug=True) |