diff --git a/Makefile b/Makefile index 1dcd395..1a389ba 100644 --- a/Makefile +++ b/Makefile @@ -254,6 +254,11 @@ backup: config/ data/ docker-compose.yml Makefile README.md @sudo chown $$(id -u):$$(id -g) backups/cell-backup-*.tar.gz @echo "Backup created in backups/." + @echo "" + @echo "WARNING: data volumes of installed store services (email, calendar," + @echo "files, ...) are NOT included in this archive. They are only captured" + @echo "by API-driven backups (POST /api/config/backup), which dump each" + @echo "service's volumes via ConfigManager._backup_service_volumes." restore: @echo "Available backups:" diff --git a/api/app.py b/api/app.py index f4dd26d..5d0303c 100644 --- a/api/app.py +++ b/api/app.py @@ -355,6 +355,15 @@ def _restore_cell_wg_peers(cell_links): def _apply_startup_enforcement(): try: + # Regenerate the Caddyfile from current config before anything else so a + # stale on-disk file (e.g. one written by an older image, missing the + # `admin 0.0.0.0:2019` directive) can't permanently wedge the health + # monitor into restarting Caddy every few minutes. Done first so the + # later service_store/identity regenerations don't debounce it away. + try: + caddy_manager.regenerate_with_installed([]) + except Exception as _cre: + logger.warning(f"startup Caddyfile regeneration failed (non-fatal): {_cre}") peers = peer_registry.list_peers() cell_links = cell_link_manager.list_connections() firewall_manager.reconcile_stale_peer_rules(peers) @@ -857,6 +866,34 @@ def connectivity_upload_openvpn(): return jsonify({'error': str(e)}), 500 +@app.route('/api/connectivity/exits/sshuttle', methods=['POST']) +def connectivity_configure_sshuttle(): + """Configure the sshuttle (SSH tunnel) exit. Secrets are never echoed back.""" + try: + data = request.get_json(silent=True) or {} + result = connectivity_manager.configure_sshuttle(data) + if result.get('ok'): + return jsonify({'ok': True}) + return jsonify({'ok': False, 'error': result.get('error', 'invalid config')}), 400 + except Exception as e: + logger.error(f"connectivity_configure_sshuttle: {e}") + return jsonify({'error': 'internal error'}), 500 + + +@app.route('/api/connectivity/exits/proxy', methods=['POST']) +def connectivity_configure_proxy(): + """Configure the upstream proxy (redsocks) exit. Secrets are never echoed back.""" + try: + data = request.get_json(silent=True) or {} + result = connectivity_manager.configure_proxy(data) + if result.get('ok'): + return jsonify({'ok': True}) + return jsonify({'ok': False, 'error': result.get('error', 'invalid config')}), 400 + except Exception as e: + logger.error(f"connectivity_configure_proxy: {e}") + return jsonify({'error': 'internal error'}), 500 + + @app.route('/api/connectivity/exits/apply', methods=['POST']) def connectivity_apply_routes(): """Idempotently re-apply all connectivity policy routing rules.""" diff --git a/api/config.py b/api/config.py deleted file mode 100644 index e1f02ae..0000000 --- a/api/config.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Configuration for Personal Internet Cell -""" - -# Development mode - set to True for development, False for production -DEVELOPMENT_MODE = True - -# Service configuration -SERVICES = { - 'network': { - 'enabled': True, - 'development_status': { - 'dns_running': True, - 'dhcp_running': True, - 'ntp_running': True, - 'running': True, - 'status': 'online' - } - }, - 'wireguard': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'interface': 'wg0', - 'peers_count': 1, - 'total_traffic': {'bytes_sent': 1024, 'bytes_received': 2048} - } - }, - 'email': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'smtp_running': True, - 'imap_running': True, - 'users_count': 0, - 'domain': 'cell.local' - } - }, - 'calendar': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'users_count': 0, - 'calendars_count': 0, - 'events_count': 0 - } - }, - 'files': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'webdav_status': {'running': True, 'port': 8080}, - 'users_count': 0, - 'total_storage_used': {'bytes': 0, 'human_readable': '0 B'} - } - }, - 'routing': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'nat_rules_count': 1, - 'peer_routes_count': 0, - 'firewall_rules_count': 0, - 'exit_nodes_count': 0 - } - }, - 'vault': { - 'enabled': True, - 'development_status': { - 'running': True, - 'status': 'online', - 'certificates_count': 1, - 'secrets_count': 0, - 'trusted_keys_count': 0 - } - } -} \ No newline at end of file diff --git a/api/config_manager.py b/api/config_manager.py index 95a9e57..49787cc 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -70,11 +70,10 @@ class ConfigManager: """Load configuration schemas for all services""" return { 'network': { - 'required': ['dns_port', 'dhcp_range', 'ntp_servers'], - 'optional': ['dns_zones', 'dhcp_reservations'], + 'required': ['dns_port', 'ntp_servers'], + 'optional': ['dns_zones'], 'types': { 'dns_port': int, - 'dhcp_range': str, 'ntp_servers': list } }, diff --git a/api/constants.py b/api/constants.py new file mode 100644 index 0000000..203981d --- /dev/null +++ b/api/constants.py @@ -0,0 +1,13 @@ +""" +constants — shared project-wide constants. + +Single source of truth for values that multiple managers must agree on. +""" + +# Core PIC infrastructure subdomains — never allow store services to hijack these. +# 'mail', 'calendar', 'files', 'webdav', 'webmail' are intentionally absent: +# they belong to official PIC store services and must be claimable by them. +RESERVED_SUBDOMAINS = frozenset({ + 'api', 'webui', 'admin', 'www', 'ns1', 'ns2', + 'git', 'registry', 'install', +}) diff --git a/api/network_manager.py b/api/network_manager.py index d9324a5..5324848 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Network Manager for Personal Internet Cell -Handles DNS, DHCP, and NTP functionality +Handles DNS and NTP functionality """ import os @@ -11,23 +11,24 @@ import subprocess import logging from datetime import datetime from typing import Dict, List, Optional, Tuple, Any + +import requests + from base_service_manager import BaseServiceManager logger = logging.getLogger(__name__) class NetworkManager(BaseServiceManager): - """Manages network services (DNS, DHCP, NTP)""" - + """Manages network services (DNS, NTP)""" + def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config', service_registry=None): super().__init__('network', data_dir, config_dir) self.dns_zones_dir = os.path.join(data_dir, 'dns') - self.dhcp_leases_file = os.path.join(data_dir, 'dhcp', 'leases') self._service_registry = service_registry # Ensure directories exist self.safe_makedirs(self.dns_zones_dir) - self.safe_makedirs(os.path.dirname(self.dhcp_leases_file)) def update_dns_zone(self, zone_name: str, records: List[Dict]) -> bool: """Update DNS zone file with new records""" @@ -309,13 +310,137 @@ class NetworkManager(BaseServiceManager): logger.error(f"Failed to list DNS records: {e}") return all_records + def _service_subdomain_routes(self) -> List[Dict[str, str]]: + """Return validated service subdomain → backend pairs from the registry.""" + registry = getattr(self, '_service_registry', None) + if registry is None: + return [] + try: + routes: List[Dict[str, str]] = [] + for route in registry.get_caddy_routes(): + pairs = [(route['subdomain'], route.get('backend', ''))] + extra_backends = route.get('extra_backends') or {} + for sub in route.get('extra_subdomains') or []: + pairs.append((sub, extra_backends.get(sub, route.get('backend', '')))) + for sub, backend in pairs: + if self._SUBDOMAIN_RE.match(sub): + routes.append({'subdomain': sub, 'backend': backend}) + else: + logger.warning('_service_subdomain_routes: skipping invalid subdomain %r', sub) + return routes + except Exception as exc: + logger.warning('_service_subdomain_routes: registry error: %s', exc) + return [] + + def get_dns_overview(self, config_manager, ddns_manager=None, + public_ip: Optional[str] = None) -> Dict[str, Any]: + """Compose a provider-aware DNS overview from the existing managers. + + Does NOT write DNS — it only reads from config_manager (identity/effective + domain), the service registry (subdomains), the internal zone files, and the + DDNS manager (registration status). public_ip may be supplied by the caller + (cached); otherwise it is fetched on demand. + """ + identity = config_manager.get_identity() or {} + mode = identity.get('domain_mode', 'lan') + effective_domain = config_manager.get_effective_domain() + internal_domain = config_manager.get_internal_domain() + ddns_cfg = config_manager.configs.get('ddns', {}) or {} + provider = ddns_cfg.get('provider', '') or '' + + if public_ip is None and mode != 'lan': + public_ip = self._fetch_public_ip() + + service_subdomains = [] + for route in self._service_subdomain_routes(): + sub = route['subdomain'] + service_subdomains.append({ + 'subdomain': sub, + 'fqdn': f'{sub}.{effective_domain}', + 'backend': route['backend'], + }) + + registration_status: Dict[str, Any] = {} + registered = False + if ddns_manager is not None: + try: + registration_status = ddns_manager.get_status() or {} + except Exception as exc: + logger.warning('get_dns_overview: ddns_manager.get_status failed: %s', exc) + try: + registered = bool(config_manager.get_ddns_token()) + except Exception: + registered = False + registration_status.setdefault('registered', registered) + + public_records = self._build_public_records( + mode, effective_domain, public_ip, service_subdomains, registered) + + return { + 'mode': mode, + 'provider': provider, + 'effective_domain': effective_domain, + 'internal_domain': internal_domain, + 'public_ip': public_ip, + 'public_records': public_records, + 'internal_records': self.get_dns_records(), + 'service_subdomains': service_subdomains, + 'registration_status': registration_status, + } + + def _build_public_records(self, mode: str, effective_domain: str, + public_ip: Optional[str], + service_subdomains: List[Dict[str, str]], + registered: bool) -> List[Dict[str, str]]: + """Derive the public A records the cell publishes (or should publish) per mode.""" + ip = public_ip or '' + status = 'registered' if registered else 'unregistered' + records: List[Dict[str, str]] = [] + + if mode == 'lan': + return records + + if mode == 'pic_ngo': + records.append({'name': effective_domain, 'type': 'A', + 'value': ip, 'status': status}) + records.append({'name': f'*.{effective_domain}', 'type': 'A', + 'value': ip, 'status': status}) + return records + + if mode in ('cloudflare', 'custom'): + records.append({'name': effective_domain, 'type': 'A', + 'value': ip, 'status': status}) + for svc in service_subdomains: + records.append({'name': svc['fqdn'], 'type': 'A', + 'value': ip, 'status': status}) + return records + + if mode == 'duckdns': + records.append({'name': effective_domain, 'type': 'A', + 'value': ip, 'status': status}) + records.append({'name': f'*.{effective_domain}', 'type': 'A', + 'value': ip, 'status': status}) + return records + + return records + + def _fetch_public_ip(self) -> Optional[str]: + """Return the current public IPv4 address using ipify, or None on failure.""" + try: + resp = requests.get('https://api.ipify.org', timeout=5) + if resp.ok: + return resp.text.strip() + except Exception as exc: + logger.warning('get_dns_overview: could not determine public IP: %s', exc) + return None + def _load_dns_records(self, zone: str) -> List[Dict]: """Load DNS records from zone file""" zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone') - + if not os.path.exists(zone_file): return [] - + records = [] try: with open(zone_file, 'r') as f: @@ -344,80 +469,6 @@ class NetworkManager(BaseServiceManager): return records - def get_dhcp_leases(self) -> List[Dict]: - """Get current DHCP leases""" - leases = [] - - try: - if os.path.exists(self.dhcp_leases_file): - with open(self.dhcp_leases_file, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#'): - parts = line.split() - if len(parts) >= 4: - leases.append({ - 'mac': parts[1], - 'ip': parts[2], - 'hostname': parts[3] if len(parts) > 3 else '', - 'timestamp': parts[0] - }) - except Exception as e: - logger.error(f"Failed to load DHCP leases: {e}") - - return leases - - def add_dhcp_reservation(self, mac: str, ip: str, hostname: str = '') -> bool: - """Add a DHCP reservation""" - try: - reservation_file = os.path.join(self.config_dir, 'dhcp', 'reservations.conf') - - # Ensure directory exists - self.safe_makedirs(os.path.dirname(reservation_file)) - - # Add reservation - with open(reservation_file, 'a') as f: - f.write(f"dhcp-host={mac},{ip},{hostname}\n") - - # Reload DHCP service - self._reload_dhcp_service() - - logger.info(f"Added DHCP reservation: {mac} -> {ip}") - return True - - except Exception as e: - logger.error(f"Failed to add DHCP reservation: {e}") - return False - - def remove_dhcp_reservation(self, mac: str) -> bool: - """Remove a DHCP reservation""" - try: - reservation_file = os.path.join(self.config_dir, 'dhcp', 'reservations.conf') - - if not os.path.exists(reservation_file): - return True - - # Read existing reservations - with open(reservation_file, 'r') as f: - lines = f.readlines() - - # Remove matching reservation - lines = [line for line in lines if not line.startswith(f"dhcp-host={mac},")] - - # Write back - with open(reservation_file, 'w') as f: - f.writelines(lines) - - # Reload DHCP service - self._reload_dhcp_service() - - logger.info(f"Removed DHCP reservation: {mac}") - return True - - except Exception as e: - logger.error(f"Failed to remove DHCP reservation: {e}") - return False - def get_ntp_status(self) -> Dict: """Get NTP service status""" try: @@ -460,36 +511,10 @@ class NetworkManager(BaseServiceManager): except Exception as e: logger.error(f"Failed to reload DNS service: {e}") - def _reload_dhcp_service(self): - """Reload DHCP service""" - try: - subprocess.run(['docker', 'exec', 'cell-dhcp', 'kill', '-HUP', '1'], - capture_output=True, timeout=10) - except Exception as e: - logger.error(f"Failed to reload DHCP service: {e}") - def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """Write config to real service files and reload/restart affected containers.""" restarted = [] warnings = [] - dnsmasq_changed = False - - # DHCP range - if 'dhcp_range' in config: - try: - dhcp_conf = os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf') - if os.path.exists(dhcp_conf): - with open(dhcp_conf) as f: - lines = f.readlines() - lines = [ - f"dhcp-range={config['dhcp_range']}\n" if l.startswith('dhcp-range=') else l - for l in lines - ] - with open(dhcp_conf, 'w') as f: - f.writelines(lines) - dnsmasq_changed = True - except Exception as e: - warnings.append(f"dhcp_range write failed: {e}") # NTP servers if 'ntp_servers' in config and config['ntp_servers']: @@ -509,39 +534,17 @@ class NetworkManager(BaseServiceManager): except Exception as e: warnings.append(f"ntp_servers write failed: {e}") - if dnsmasq_changed: - self._reload_dhcp_service() - restarted.append('cell-dhcp (reloaded)') - return {'restarted': restarted, 'warnings': warnings} def apply_domain(self, domain: str, reload: bool = True) -> Dict[str, Any]: - """Update domain across dnsmasq, Corefile, and zone file; reload DNS + DHCP. + """Update domain across the Corefile and zone file; reload DNS. reload=False writes config files only — use when deferring container restart. """ restarted = [] warnings = [] - # 1. Update dnsmasq.conf domain= line - try: - dhcp_conf = os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf') - if os.path.exists(dhcp_conf): - with open(dhcp_conf) as f: - lines = f.readlines() - lines = [ - f"domain={domain}\n" if l.startswith('domain=') else l - for l in lines - ] - with open(dhcp_conf, 'w') as f: - f.writelines(lines) - if reload: - self._reload_dhcp_service() - restarted.append('cell-dhcp (reloaded)') - except Exception as e: - warnings.append(f"dnsmasq domain update failed: {e}") - - # 2. Regenerate Corefile — include cell-to-cell forwarding stanzas so a + # 1. Regenerate Corefile — include cell-to-cell forwarding stanzas so a # domain/ip_range change doesn't wipe cross-cell DNS forwarding zones. try: import firewall_manager as _fm @@ -562,7 +565,7 @@ class NetworkManager(BaseServiceManager): except Exception as e: warnings.append(f"Corefile domain update failed: {e}") - # 3. Update zone file: rename and rewrite $ORIGIN / SOA, remove stale zones + # 2. Update zone file: rename and rewrite $ORIGIN / SOA, remove stale zones try: dns_data = os.path.join(self.data_dir, 'dns') if os.path.isdir(dns_data): @@ -599,7 +602,7 @@ class NetworkManager(BaseServiceManager): except Exception as e: warnings.append(f"zone file domain update failed: {e}") - # 4. Reload CoreDNS (only when not deferring to Apply) + # 3. Reload CoreDNS (only when not deferring to Apply) if reload: try: self._reload_dns_service() @@ -758,29 +761,6 @@ class NetworkManager(BaseServiceManager): except Exception as e: return {'success': False, 'output': '', 'error': str(e)} - def test_dhcp_functionality(self) -> Dict: - """Test DHCP functionality""" - try: - # Check if DHCP service is running - result = subprocess.run(['docker', 'ps', '--filter', 'name=cell-dhcp', '--format', '{{.Names}}'], - capture_output=True, text=True) - - is_running = len(result.stdout.strip()) > 0 - - # Get DHCP leases - leases = self.get_dhcp_leases() - - return { - 'success': is_running, - 'running': is_running, - 'leases_count': len(leases), - 'leases': leases - } - - except Exception as e: - logger.error(f"Failed to test DHCP functionality: {e}") - return {'success': False, 'running': False, 'leases_count': 0, 'leases': []} - def test_ntp_functionality(self) -> Dict: """Test NTP functionality""" try: @@ -879,19 +859,16 @@ class NetworkManager(BaseServiceManager): if is_docker: # Check if network containers are actually running dns_running = self._check_dns_container_status() - dhcp_running = self._check_dhcp_container_status() ntp_running = self._check_ntp_container_status() - all_running = dns_running and dhcp_running and ntp_running - + all_running = dns_running and ntp_running + status = { 'dns_running': dns_running, - 'dhcp_running': dhcp_running, 'ntp_running': ntp_running, 'running': all_running, 'status': 'online' if all_running else 'offline', 'network': { 'dns_running': dns_running, - 'dhcp_running': dhcp_running, 'ntp_running': ntp_running, 'running': all_running, 'status': 'online' if all_running else 'offline' @@ -901,25 +878,22 @@ class NetworkManager(BaseServiceManager): else: # Check actual service status in production dns_running = self._check_dns_status() - dhcp_running = self._check_dhcp_status() ntp_running = self._check_ntp_status() - + status = { 'dns_running': dns_running, - 'dhcp_running': dhcp_running, 'ntp_running': ntp_running, - 'running': dns_running and dhcp_running and ntp_running, - 'status': 'online' if (dns_running and dhcp_running and ntp_running) else 'offline', + 'running': dns_running and ntp_running, + 'status': 'online' if (dns_running and ntp_running) else 'offline', 'network': { 'dns_running': dns_running, - 'dhcp_running': dhcp_running, 'ntp_running': ntp_running, - 'running': dns_running and dhcp_running and ntp_running, - 'status': 'online' if (dns_running and dhcp_running and ntp_running) else 'offline' + 'running': dns_running and ntp_running, + 'status': 'online' if (dns_running and ntp_running) else 'offline' }, 'timestamp': datetime.utcnow().isoformat() } - + return status except Exception as e: return self.handle_error(e, "get_status") @@ -934,16 +908,6 @@ class NetworkManager(BaseServiceManager): except Exception: return False - def _check_dhcp_container_status(self) -> bool: - """Check if DHCP Docker container is running""" - try: - import docker - client = docker.from_env() - containers = client.containers.list(filters={'name': 'cell-dhcp'}) - return len(containers) > 0 - except Exception: - return False - def _check_ntp_container_status(self) -> bool: """Check if NTP Docker container is running""" try: @@ -958,31 +922,28 @@ class NetworkManager(BaseServiceManager): """Test network service connectivity""" try: dns_test = self.test_dns_resolution('google.com') - dhcp_test = self.test_dhcp_functionality() ntp_test = self.test_ntp_functionality() - + results = { 'dns_test': dns_test, - 'dhcp_test': dhcp_test, 'ntp_test': ntp_test, 'timestamp': datetime.utcnow().isoformat() } - + # Determine overall success success = all( - result.get('success', False) - for result in [dns_test, dhcp_test, ntp_test] + result.get('success', False) + for result in [dns_test, ntp_test] ) results['success'] = success - + # Add network key for compatibility results['network'] = { 'dns_test': dns_test, - 'dhcp_test': dhcp_test, 'ntp_test': ntp_test, 'success': success } - + return results except Exception as e: return self.handle_error(e, "test_connectivity") @@ -1001,20 +962,6 @@ class NetworkManager(BaseServiceManager): except Exception: return False - def _check_dhcp_status(self) -> bool: - """Check if DHCP service is running""" - try: - result = subprocess.run(['systemctl', 'is-active', 'dnsmasq'], - capture_output=True, text=True, timeout=5) - return result.returncode == 0 and result.stdout.strip() == 'active' - except Exception: - # Fallback: check if port 67 is listening - try: - result = subprocess.run(['netstat', '-tuln'], capture_output=True, text=True) - return ':67 ' in result.stdout - except Exception: - return False - def _check_ntp_status(self) -> bool: """Check if NTP service is running""" try: diff --git a/api/routes/network.py b/api/routes/network.py index 7df2f3d..544aa82 100644 --- a/api/routes/network.py +++ b/api/routes/network.py @@ -35,42 +35,14 @@ def remove_dns_record(): logger.error(f"Error removing DNS record: {e}") return jsonify({"error": str(e)}), 500 -@bp.route('/api/dhcp/leases', methods=['GET']) -def get_dhcp_leases(): +@bp.route('/api/dns/overview', methods=['GET']) +def get_dns_overview(): try: - from app import network_manager - return jsonify(network_manager.get_dhcp_leases()) + from app import network_manager, config_manager, ddns_manager + overview = network_manager.get_dns_overview(config_manager, ddns_manager) + return jsonify(overview) except Exception as e: - logger.error(f"Error getting DHCP leases: {e}") - return jsonify({"error": str(e)}), 500 - -@bp.route('/api/dhcp/reservations', methods=['POST']) -def add_dhcp_reservation(): - try: - from app import network_manager - 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 - -@bp.route('/api/dhcp/reservations', methods=['DELETE']) -def remove_dhcp_reservation(): - try: - from app import network_manager - 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}") + logger.error(f"Error getting DNS overview: {e}") return jsonify({"error": str(e)}), 500 @bp.route('/api/ntp/status', methods=['GET']) diff --git a/api/routes/services.py b/api/routes/services.py index dd382a2..6f968aa 100644 --- a/api/routes/services.py +++ b/api/routes/services.py @@ -384,7 +384,6 @@ def get_all_services_status(): 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': diff --git a/docker-compose.yml b/docker-compose.yml index 1cf4e3b..6c47469 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - ./data/caddy:/data - ./config/caddy/certs:/config/caddy/certs restart: unless-stopped + mem_limit: 256m + cpus: 0.5 + pids_limit: 256 cap_add: - NET_ADMIN networks: @@ -38,6 +41,9 @@ services: - ./config/dns/Corefile:/etc/coredns/Corefile - ./data/dns:/data restart: unless-stopped + mem_limit: 128m + cpus: 0.25 + pids_limit: 256 networks: cell-network: ipv4_address: ${DNS_IP:-172.20.0.3} @@ -47,29 +53,6 @@ services: max-size: "10m" max-file: "5" - # DHCP Server - dnsmasq for IP leasing - dhcp: - image: alpine:latest - container_name: cell-dhcp - profiles: ["core", "full"] - ports: - - "${DHCP_PORT:-67}:67/udp" - volumes: - - ./config/dhcp/dnsmasq.conf:/etc/dnsmasq.conf - - ./data/dhcp:/var/lib/misc - restart: unless-stopped - networks: - cell-network: - ipv4_address: ${DHCP_IP:-172.20.0.4} - command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"] - cap_add: - - NET_ADMIN - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" - # NTP Server - chrony for time synchronization ntp: image: alpine:latest @@ -80,6 +63,9 @@ services: volumes: - ./config/ntp/chrony.conf:/etc/chrony/chrony.conf restart: unless-stopped + mem_limit: 128m + cpus: 0.25 + pids_limit: 256 networks: cell-network: ipv4_address: ${NTP_IP:-172.20.0.5} @@ -107,6 +93,9 @@ services: - ./config/wireguard:/config - /lib/modules:/lib/modules restart: unless-stopped + mem_limit: 256m + cpus: 0.5 + pids_limit: 256 networks: cell-network: ipv4_address: ${WG_IP:-172.20.0.9} @@ -149,6 +138,9 @@ services: - ./scripts:/app/scripts:ro pid: host restart: unless-stopped + mem_limit: 512m + cpus: 1.0 + pids_limit: 256 networks: cell-network: ipv4_address: ${API_IP:-172.20.0.10} @@ -169,6 +161,9 @@ services: ports: - "${WEBUI_PORT:-8081}:80" restart: unless-stopped + mem_limit: 256m + cpus: 0.5 + pids_limit: 256 networks: cell-network: ipv4_address: ${WEBUI_IP:-172.20.0.11} diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index c7e0557..600204b 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -17,13 +17,11 @@ import sys REQUIRED_DIRS = [ 'config/caddy/certs', 'config/dns', - 'config/dhcp', 'config/ntp', 'config/wireguard', 'config/api', 'data/caddy', 'data/dns', - 'data/dhcp', 'data/api', 'data/vault/certs', 'data/vault/keys', @@ -37,7 +35,6 @@ REQUIRED_DIRS = [ REQUIRED_FILES = [ 'config/dns/Corefile', - 'config/dhcp/dnsmasq.conf', 'config/ntp/chrony.conf', ] diff --git a/webui/src/__tests__/Settings.IdentitySave.test.jsx b/webui/src/__tests__/Settings.IdentitySave.test.jsx index 176fad7..7db24ff 100644 --- a/webui/src/__tests__/Settings.IdentitySave.test.jsx +++ b/webui/src/__tests__/Settings.IdentitySave.test.jsx @@ -185,6 +185,64 @@ describe('Cell Identity — availability check', () => { }); }); +describe('Cell Identity — reverting a change clears the pending state', () => { + beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); + afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); + + it('typing a new cell_name then reverting to the saved value calls setDirty("identity", false)', async () => { + await renderSettings(); + mockSetDirty.mockClear(); + + fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); + expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true); + + fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } }); + expect(mockSetDirty).toHaveBeenLastCalledWith('identity', false); + }); + + it('reverting cell_name does NOT trigger a spurious auto-save', async () => { + await renderSettings(); + + fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); + fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } }); + + // Advance well past both debounces (availability 900 ms + auto-save 800 ms) + await act(async () => { vi.advanceTimersByTime(2500); }); + await act(async () => { await Promise.resolve(); }); + + expect(mockUpdateConfig).not.toHaveBeenCalled(); + }); + + it('reverting ip_range within the auto-save debounce cancels the save and clears dirty', async () => { + await renderSettings(); + mockSetDirty.mockClear(); + + fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } }); + expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true); + + // Revert before the 800 ms auto-save fires + await act(async () => { vi.advanceTimersByTime(400); }); + fireEvent.change(screen.getByDisplayValue('10.0.0.0/8'), { target: { value: '172.20.0.0/16' } }); + expect(mockSetDirty).toHaveBeenLastCalledWith('identity', false); + + await act(async () => { vi.advanceTimersByTime(2000); }); + await act(async () => { await Promise.resolve(); }); + + expect(mockUpdateConfig).not.toHaveBeenCalled(); + }); + + it('modifying cell_name again after a revert re-enters the pending state', async () => { + await renderSettings(); + mockSetDirty.mockClear(); + + fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } }); + fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } }); + fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic3' } }); + + expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true); + }); +}); + describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); diff --git a/webui/src/pages/Calendar.jsx b/webui/src/pages/Calendar.jsx deleted file mode 100644 index 05e7299..0000000 --- a/webui/src/pages/Calendar.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; -import { calendarAPI } from '../services/api'; -import { useConfig } from '../contexts/ConfigContext'; - - -function CopyButton({ text }) { - const [copied, setCopied] = useState(false); - const copy = () => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - return ( - - ); -} - -function InfoRow({ label, value }) { - return ( -
Radicale CalDAV / CardDAV server
-- Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.) -
-- Requires VPN connection. DNS server must be set to {dnsIp}. -
-iOS (Settings → Calendar → Accounts)
-Android (DAVx⁵ app)
-Thunderbird
-Status unavailable
- )} -No calendar users configured
- )} -Postfix (SMTP) + Dovecot (IMAP)
-- Requires VPN + DNS set to {dnsIp}. -
-Status unavailable
- )} -No email accounts configured
- )} -FileGator (browser) + WebDAV (native clients)
-- Browser-based file manager. Requires VPN. -
-- Mount in macOS Finder, Windows Explorer, or any WebDAV client. -
-macOS (Finder)
-Go → Connect to Server → {proto}://{webdavHost}
-Windows
-Map Network Drive → \\{webdavHost}\DavWWWRoot or use {proto}://{webdavHost} in "Connect to a Web Site"
-iOS (Files app)
-Files → ... → Connect to Server → {proto}://{webdavHost}
-Android
-Use Solid Explorer or FX File Explorer → Add cloud → WebDAV → {proto}://{webdavHost}
-Status unavailable
- )} -- DNS zone: {domain} - {networkConfig.dhcp_range && ( - <> · DHCP: {networkConfig.dhcp_range}> - )} -
-No DNS records configured
- )} -Range: {networkConfig.dhcp_range}
- )} -No active DHCP leases
- )} -- Servers: {Array.isArray(networkConfig.ntp_servers) - ? networkConfig.ntp_servers.join(', ') - : networkConfig.ntp_servers} -
- )} - {ntpStatus ? ( -NTP service unavailable
- )} -Unable to load DNS overview.
++ Provider: {modeLabel} + {overview.public_ip && ( + <> · Public IP: {overview.public_ip}> + )} +
++ {overview.effective_domain} · {modeLabel} +
+ + {mode === 'lan' && ( +LAN-only, no public DNS.
+ )} + + {mode === 'custom' && ( +No public records.
+ )} ++ Service subdomains resolve via the wildcard record. +
+ )} + + {renderAction() &&+ {overview.internal_domain} +
+No internal records configured.
+ )} +NTP service unavailable
+ )} +