From c41cadafb474d7ac5d23ff0eb981612b4ad8981b Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Wed, 10 Jun 2026 08:50:00 -0400 Subject: [PATCH] refactor: Network Services rebuilt, DHCP decommissioned, infra cleanup Network Services page is rebuilt around real API data: GET /api/dns/overview returns provider-aware records; per-service Cloudflare sync is exposed via POST /api/ddns/sync; effective domain is displayed so operators can verify what external name resolves to the cell; NTP status reflects the actual systemd-timesyncd state rather than a hardcoded boolean. DHCP is fully decommissioned: the cell-dhcp container is removed from docker-compose.yml, DHCP methods are stripped from network_manager, the setup_cell script no longer seeds DHCP config, and the Settings DHCP field is gone. DHCP was never a PIC responsibility and the container was consuming resources for no benefit. Dead code removed: api/config.py (superseded by config_manager), the standalone Email/Calendar/Files pages (these are now optional store services and do not need dedicated pages). api/constants.py is introduced to hold RESERVED_SUBDOMAINS in one place rather than scattered literals. Docker resource limits (mem_limit, cpus, pids_limit) are added to all compose services so a runaway process cannot starve the host. Makefile gains a warning before the backup target so operators are not surprised by the archive path. Settings same/accept state fix ensures the Cell Identity section correctly shows the accept/discard banner and does not flash a false-positive change indicator on first load. Co-Authored-By: Claude Fable 5 --- Makefile | 5 + api/app.py | 37 ++ api/config.py | 83 ---- api/config_manager.py | 5 +- api/constants.py | 13 + api/network_manager.py | 353 +++++++--------- api/routes/network.py | 40 +- api/routes/services.py | 1 - docker-compose.yml | 41 +- scripts/setup_cell.py | 3 - .../__tests__/Settings.IdentitySave.test.jsx | 58 +++ webui/src/pages/Calendar.jsx | 187 --------- webui/src/pages/Email.jsx | 174 -------- webui/src/pages/Files.jsx | 186 -------- webui/src/pages/NetworkServices.jsx | 397 ++++++++++++------ webui/src/pages/Settings.jsx | 51 +-- 16 files changed, 575 insertions(+), 1059 deletions(-) delete mode 100644 api/config.py create mode 100644 api/constants.py delete mode 100644 webui/src/pages/Calendar.jsx delete mode 100644 webui/src/pages/Email.jsx delete mode 100644 webui/src/pages/Files.jsx 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 ( -
- {label} -
- {value} - -
-
- ); -} - -function Calendar() { - const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig(); - const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; - const proto = domain_mode === 'lan' ? 'http' : 'https'; - const cellHost = `calendar.${svcDomain}`; - const calendarIp = service_ips.vip_calendar || '172.20.0.21'; - const dnsIp = service_ips.dns || '172.20.0.3'; - const calendarPort = service_configs.calendar?.port ?? 5232; - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchCalendarData(); - }, []); - - const fetchCalendarData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - calendarAPI.getUsers(), - calendarAPI.getStatus() - ]); - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch calendar data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Calendar & Contacts

-

Radicale CalDAV / CardDAV server

-
- -
- {/* Connection Info */} -
-
- -

Connect your device

-
-

- Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.) -

-
- - - - - - - -
-

- Requires VPN connection. DNS server must be set to {dnsIp}. -

-
- - {/* iOS / Android quick guide */} -
-
- -

Quick setup guide

-
-
-
-

iOS (Settings → Calendar → Accounts)

-
    -
  1. Add Account → Other → Add CalDAV Account
  2. -
  3. Server: {cellHost}
  4. -
  5. Enter username & password
  6. -
  7. For contacts: Add CardDAV Account, same server
  8. -
-
-
-

Android (DAVx⁵ app)

-
    -
  1. Install DAVx⁵ from Play Store / F-Droid
  2. -
  3. Login with URL: {proto}://{cellHost}/
  4. -
  5. Select calendars & address books to sync
  6. -
-
-
-

Thunderbird

-
    -
  1. Calendar → New Calendar → On the Network
  2. -
  3. Format: CalDAV, Location: {proto}://{cellHost}/
  4. -
-
-
-
- - {/* Status */} -
-
- -

Service Status

-
- {status ? ( -
-
- Radicale: - Running -
-
- CalDAV: - Active -
-
- CardDAV: - Active -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} -
-
- -

Calendar Users

-
-
- {users.length > 0 ? ( - users.map((user, index) => ( -
- {user.username} - {user.calendars || 0} calendars -
- )) - ) : ( -

No calendar users configured

- )} -
-
-
-
- ); -} - -export default Calendar; diff --git a/webui/src/pages/Email.jsx b/webui/src/pages/Email.jsx deleted file mode 100644 index 4fad071..0000000 --- a/webui/src/pages/Email.jsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; -import { emailAPI } 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 ( -
- {label} -
- {value} - -
-
- ); -} - -function Email() { - const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig(); - const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; - const proto = domain_mode === 'lan' ? 'http' : 'https'; - const cellHost = `mail.${svcDomain}`; - const emailCfg = service_configs.email || {}; - const mailIp = service_ips.vip_mail || '172.20.0.23'; - const dnsIp = service_ips.dns || '172.20.0.3'; - const imapPort = emailCfg.imap_port ?? 993; - const smtpPort = emailCfg.smtp_port ?? 25; - const webmailPort = emailCfg.webmail_port ?? 8888; - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchEmailData(); - }, []); - - const fetchEmailData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - emailAPI.getUsers(), - emailAPI.getStatus() - ]); - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch email data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Email Services

-

Postfix (SMTP) + Dovecot (IMAP)

-
- -
- {/* Incoming mail */} -
-
- -

Incoming mail (IMAP)

-
-
- - - - -
-
- - {/* Outgoing mail */} -
-
- -

Outgoing mail (SMTP)

-
-
- - - - -
-
- - {/* Webmail */} -
-
- -

Webmail

-
-
- - - - -
-

- Requires VPN + DNS set to {dnsIp}. -

-
- - {/* Status */} -
-
- -

Service Status

-
- {status ? ( -
-
- Postfix (SMTP): - Running -
-
- Dovecot (IMAP): - Running -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} -
-
- -

Email Accounts

-
-
- {users.length > 0 ? ( - users.map((user, index) => ( -
- {user.username} - {user.domain} -
- )) - ) : ( -

No email accounts configured

- )} -
-
-
-
- ); -} - -export default Email; diff --git a/webui/src/pages/Files.jsx b/webui/src/pages/Files.jsx deleted file mode 100644 index bb1999f..0000000 --- a/webui/src/pages/Files.jsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useEffect } from 'react'; -import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react'; -import { fileAPI } 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 ( -
- {label} -
- {value} - -
-
- ); -} - -function Files() { - const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig(); - const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; - const proto = domain_mode === 'lan' ? 'http' : 'https'; - const filesHost = `files.${svcDomain}`; - const webdavHost = `webdav.${svcDomain}`; - const filesIp = service_ips.vip_files || '172.20.0.22'; - const webdavIp = service_ips.vip_webdav || '172.20.0.24'; - const filesCfg = service_configs.files || {}; - const webdavPort = filesCfg.port ?? 8080; - const filegatorPort = filesCfg.manager_port ?? 8082; - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchFilesData(); - }, []); - - const fetchFilesData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - fileAPI.getUsers(), - fileAPI.getStatus() - ]); - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch files data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

File Storage

-

FileGator (browser) + WebDAV (native clients)

-
- -
- {/* File Manager */} -
-
- -

Web file manager

-
-
- - - -
-

- Browser-based file manager. Requires VPN. -

-
- - {/* WebDAV */} -
-
- -

WebDAV (mount as drive)

-
-
- - - - -
-

- Mount in macOS Finder, Windows Explorer, or any WebDAV client. -

-
- - {/* OS quick guide */} -
-
- -

Mount as network drive

-
-
-
-

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 */} -
-
- -

Service Status

-
- {status ? ( -
-
- FileGator: - Running -
-
- WebDAV: - Running -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} - {users.length > 0 && ( -
-
- -

Storage Users

-
-
- {users.map((user, index) => ( -
- {user.username} - {user.storage_used || '0'} MB -
- ))} -
-
- )} -
-
- ); -} - -export default Files; diff --git a/webui/src/pages/NetworkServices.jsx b/webui/src/pages/NetworkServices.jsx index 5830743..84a12ed 100644 --- a/webui/src/pages/NetworkServices.jsx +++ b/webui/src/pages/NetworkServices.jsx @@ -1,138 +1,259 @@ -import { useState, useEffect } from 'react'; -import { Network, Server, Clock } from 'lucide-react'; -import { networkAPI, cellAPI } from '../services/api'; -import { useConfig } from '../contexts/ConfigContext'; - -function NetworkServices() { - const { domain = 'cell' } = useConfig(); - const [dnsRecords, setDnsRecords] = useState([]); - const [dhcpLeases, setDhcpLeases] = useState([]); - const [ntpStatus, setNtpStatus] = useState(null); - const [networkConfig, setNetworkConfig] = useState({}); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchNetworkData(); - }, []); - - const fetchNetworkData = async () => { - try { - const [dnsResponse, dhcpResponse, ntpResponse, cfgResponse] = await Promise.all([ - networkAPI.getDNSRecords(), - networkAPI.getDHCPLeases(), - networkAPI.getNTPStatus(), - cellAPI.getConfig(), - ]); - - setDnsRecords(dnsResponse.data); - setDhcpLeases(dhcpResponse.data); - setNtpStatus(ntpResponse.data); - setNetworkConfig(cfgResponse.data?.service_configs?.network || {}); - } catch (error) { - console.error('Failed to fetch network data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Network Services

-

- DNS zone: {domain} - {networkConfig.dhcp_range && ( - <> · DHCP: {networkConfig.dhcp_range} - )} -

-
- -
- {/* DNS Records */} -
-
- -

DNS Records

-
-
- {dnsRecords.length > 0 ? ( - dnsRecords.map((record, index) => ( -
-
- {record.name} - .{record.zone} -
- {record.value} -
- )) - ) : ( -

No DNS records configured

- )} -
-
- - {/* DHCP Leases */} -
-
- -

DHCP Leases

-
- {networkConfig.dhcp_range && ( -

Range: {networkConfig.dhcp_range}

- )} -
- {dhcpLeases.length > 0 ? ( - dhcpLeases.map((lease, index) => ( -
- {lease.hostname || 'Unknown'} - {lease.ip} -
- )) - ) : ( -

No active DHCP leases

- )} -
-
- - {/* NTP Status */} -
-
- -

NTP Status

-
- {networkConfig.ntp_servers && ( -

- Servers: {Array.isArray(networkConfig.ntp_servers) - ? networkConfig.ntp_servers.join(', ') - : networkConfig.ntp_servers} -

- )} - {ntpStatus ? ( -
-
- Status: - Online -
-
- Sync: - Synchronized -
-
- ) : ( -

NTP service unavailable

- )} -
-
-
- ); -} - -export default NetworkServices; \ No newline at end of file +import { useState, useEffect } from 'react'; +import { Network, Globe, Clock, RefreshCw, AlertTriangle } from 'lucide-react'; +import { networkAPI, ddnsAPI } from '../services/api'; + +const MODE_LABELS = { + lan: 'LAN-only', + pic_ngo: 'pic.ngo DDNS', + cloudflare: 'Cloudflare', + custom: 'Custom registrar', + duckdns: 'DuckDNS', +}; + +function statusBadge(status) { + if (status === 'registered') { + return registered; + } + if (status === 'unregistered') { + return unregistered; + } + return {status || 'unknown'}; +} + +function RecordRow({ record }) { + return ( +
+
+ {record.name} + {record.type} +
+
+ {record.value || '—'} + {record.status && statusBadge(record.status)} +
+
+ ); +} + +function NetworkServices() { + const [overview, setOverview] = useState(null); + const [ntpStatus, setNtpStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [actionBusy, setActionBusy] = useState(false); + const [actionMsg, setActionMsg] = useState(null); + + const fetchData = async () => { + try { + const [overviewResponse, ntpResponse] = await Promise.all([ + networkAPI.getDNSOverview(), + networkAPI.getNTPStatus(), + ]); + setOverview(overviewResponse.data); + setNtpStatus(ntpResponse.data); + } catch (error) { + console.error('Failed to fetch network data:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const runAction = async (fn, successMsg) => { + setActionBusy(true); + setActionMsg(null); + try { + await fn(); + setActionMsg({ type: 'success', text: successMsg }); + await fetchData(); + } catch (error) { + const text = error?.response?.data?.error || 'Action failed'; + setActionMsg({ type: 'error', text }); + } finally { + setActionBusy(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!overview) { + return ( +
+

Network Services

+

Unable to load DNS overview.

+
+ ); + } + + const mode = overview.mode; + const modeLabel = MODE_LABELS[mode] || mode; + const ntpRunning = ntpStatus?.running === true; + + const renderAction = () => { + if (mode === 'pic_ngo' || mode === 'duckdns') { + const label = mode === 'pic_ngo' ? 'Register / Update IP' : 'Update IP'; + return ( + + ); + } + if (mode === 'cloudflare') { + return ( + + ); + } + return null; + }; + + return ( +
+
+

Network Services

+

+ Provider: {modeLabel} + {overview.public_ip && ( + <> · Public IP: {overview.public_ip} + )} +

+
+ + {actionMsg && ( +
+ {actionMsg.text} +
+ )} + +
+ {/* Public DNS */} +
+
+ +

Public DNS

+
+

+ {overview.effective_domain} · {modeLabel} +

+ + {mode === 'lan' && ( +

LAN-only, no public DNS.

+ )} + + {mode === 'custom' && ( +
+ + Not managed by PIC. Create these records at your registrar. +
+ )} + + {mode !== 'lan' && ( +
+ {overview.public_records.length > 0 ? ( + overview.public_records.map((record, index) => ( + + )) + ) : ( +

No public records.

+ )} +
+ )} + + {(mode === 'pic_ngo' || mode === 'duckdns') && overview.service_subdomains.length > 0 && ( +

+ Service subdomains resolve via the wildcard record. +

+ )} + + {renderAction() &&
{renderAction()}
} +
+ + {/* Internal zone */} +
+
+ +

Internal zone

+
+

+ {overview.internal_domain} +

+
+ {overview.internal_records.length > 0 ? ( + overview.internal_records.map((record, index) => ( + + )) + ) : ( +

No internal records configured.

+ )} +
+
+ + {/* NTP Status */} +
+
+ +

NTP Status

+
+ {ntpStatus ? ( +
+
+ Status: + + {ntpRunning ? 'Online' : 'Offline'} + +
+
+ Sync: + + {ntpRunning ? 'Synchronized' : 'Unknown'} + +
+
+ ) : ( +

NTP service unavailable

+ )} +
+
+
+ ); +} + +export default NetworkServices; diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index 2565377..85ff5b2 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -14,7 +14,7 @@ import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfi const RESTORE_SERVICES = [ { key: 'identity', label: 'Identity (cell name, domain, IP range)' }, - { key: 'network', label: 'Network (DNS, DHCP, NTP)' }, + { key: 'network', label: 'Network (DNS, NTP)' }, { key: 'wireguard', label: 'WireGuard VPN' }, { key: 'email', label: 'Email' }, { key: 'calendar', label: 'Calendar & Contacts' }, @@ -131,13 +131,6 @@ function validateServiceConfig(key, data) { }; if (key === 'network') { port('dns_port'); - if (data.dhcp_range) { - const parts = data.dhcp_range.split(','); - if (parts[0]?.trim() && !isValidIp(parts[0].trim())) - errors.dhcp_range = `Start IP is invalid`; - else if (parts[1]?.trim() && !isValidIp(parts[1].trim())) - errors.dhcp_range = `End IP is invalid`; - } const badNtp = (data.ntp_servers || []).find(s => !isValidDomainOrIp(s)); if (badNtp !== undefined) errors.ntp_servers = `"${badNtp}" is not a valid hostname or IP`; } @@ -268,9 +261,6 @@ function NetworkForm({ data, onChange, errors = {} }) { onChange({ ...data, dns_port: v })} min={1} max={65535} /> - - onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" /> - onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" /> @@ -322,7 +312,7 @@ function VaultForm({ data, onChange }) { // Service configs shown in Settings — email/calendar/files moved to their own pages const SERVICE_DEFS = [ - { key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } }, + { key: 'network', label: 'Network (DNS/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, ntp_servers: [] } }, { key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } }, { key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } }, { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } }, @@ -338,7 +328,7 @@ function Settings() { // identity const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identityDirty, setIdentityDirty] = useState(false); - const [loadedCellName, setLoadedCellName] = useState(''); + const [loadedIdentity, setLoadedIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [effectiveDomain, setEffectiveDomain] = useState(''); // DDNS @@ -385,12 +375,13 @@ function Settings() { ]); const cfg = cfgRes.data; if (certRes?.data) setCertStatus(certRes.data); - setIdentity({ + const loadedIdent = { cell_name: cfg.cell_name || '', domain: cfg.domain || '', ip_range: cfg.ip_range || '', - }); - setLoadedCellName(cfg.cell_name || ''); + }; + setIdentity(loadedIdent); + setLoadedIdentity(loadedIdent); setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || ''); setIdentityDirty(false); setDomainMode(cfg.domain_mode || 'lan'); @@ -458,7 +449,7 @@ function Settings() { if (domainMode !== 'pic_ngo') { setPicAvail(null); return; } const name = identity.cell_name; // No check needed when the name hasn't changed from what's already registered. - if (!name || name === loadedCellName) { setPicAvail(null); return; } + if (!name || name === loadedIdentity.cell_name) { setPicAvail(null); return; } clearTimeout(picAvailTimerRef.current); setPicAvail(null); picAvailTimerRef.current = setTimeout(async () => { @@ -471,7 +462,19 @@ function Settings() { } }, 900); return () => clearTimeout(picAvailTimerRef.current); - }, [identity.cell_name, domainMode, loadedCellName]); // eslint-disable-line react-hooks/exhaustive-deps + }, [identity.cell_name, domainMode, loadedIdentity.cell_name]); // eslint-disable-line react-hooks/exhaustive-deps + + // Dirty state is a comparison against the last loaded/saved values, so reverting + // an edit back to the saved value leaves the pending Accept/Discard state cleanly. + const updateIdentityField = (field, value) => { + const next = { ...identity, [field]: value }; + setIdentity(next); + const dirty = next.cell_name !== loadedIdentity.cell_name + || next.domain !== loadedIdentity.domain + || next.ip_range !== loadedIdentity.ip_range; + setIdentityDirty(dirty); + draftConfig?.setDirty('identity', dirty); + }; const saveIdentity = useCallback(async () => { if (ipRangeError || cellNameError || domainError) return; @@ -482,7 +485,7 @@ function Settings() { try { const res = await cellAPI.updateConfig(identity); setIdentityDirty(false); - setLoadedCellName(identity.cell_name); + setLoadedIdentity(identity); draftConfig?.setDirty('identity', false); if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning')); // Refresh to get updated domain_name after DDNS registration @@ -651,10 +654,10 @@ function Settings() { if (ipRangeError || cellNameError || domainError) return; // pic_ngo cell name changes require DDNS re-registration (irreversible: releases the // old subdomain). Never auto-save these — the user must explicitly press Accept. - if (domainMode === 'pic_ngo' && identity.cell_name !== loadedCellName) return; + if (domainMode === 'pic_ngo' && identity.cell_name !== loadedIdentity.cell_name) return; const timer = setTimeout(() => saveIdentityRef.current(), 800); return () => clearTimeout(timer); - }, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, loadedCellName]); // eslint-disable-line react-hooks/exhaustive-deps + }, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, loadedIdentity]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const timers = SERVICE_DEFS @@ -802,7 +805,7 @@ function Settings() {
{ setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + onChange={(v) => updateIdentityField('cell_name', v)} placeholder="mycell" maxLength={255} /> @@ -827,7 +830,7 @@ function Settings() { { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + onChange={(v) => updateIdentityField('domain', v)} placeholder="cell" maxLength={255} /> @@ -846,7 +849,7 @@ function Settings() { { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + onChange={(v) => updateIdentityField('ip_range', v)} placeholder="172.20.0.0/16" />