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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:50:00 -04:00
parent 6232ef23a9
commit c41cadafb4
16 changed files with 575 additions and 1059 deletions
+5
View File
@@ -254,6 +254,11 @@ backup:
config/ data/ docker-compose.yml Makefile README.md config/ data/ docker-compose.yml Makefile README.md
@sudo chown $$(id -u):$$(id -g) backups/cell-backup-*.tar.gz @sudo chown $$(id -u):$$(id -g) backups/cell-backup-*.tar.gz
@echo "Backup created in backups/." @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: restore:
@echo "Available backups:" @echo "Available backups:"
+37
View File
@@ -355,6 +355,15 @@ def _restore_cell_wg_peers(cell_links):
def _apply_startup_enforcement(): def _apply_startup_enforcement():
try: 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() peers = peer_registry.list_peers()
cell_links = cell_link_manager.list_connections() cell_links = cell_link_manager.list_connections()
firewall_manager.reconcile_stale_peer_rules(peers) firewall_manager.reconcile_stale_peer_rules(peers)
@@ -857,6 +866,34 @@ def connectivity_upload_openvpn():
return jsonify({'error': str(e)}), 500 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']) @app.route('/api/connectivity/exits/apply', methods=['POST'])
def connectivity_apply_routes(): def connectivity_apply_routes():
"""Idempotently re-apply all connectivity policy routing rules.""" """Idempotently re-apply all connectivity policy routing rules."""
-83
View File
@@ -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
}
}
}
+2 -3
View File
@@ -70,11 +70,10 @@ class ConfigManager:
"""Load configuration schemas for all services""" """Load configuration schemas for all services"""
return { return {
'network': { 'network': {
'required': ['dns_port', 'dhcp_range', 'ntp_servers'], 'required': ['dns_port', 'ntp_servers'],
'optional': ['dns_zones', 'dhcp_reservations'], 'optional': ['dns_zones'],
'types': { 'types': {
'dns_port': int, 'dns_port': int,
'dhcp_range': str,
'ntp_servers': list 'ntp_servers': list
} }
}, },
+13
View File
@@ -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',
})
+150 -203
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Network Manager for Personal Internet Cell Network Manager for Personal Internet Cell
Handles DNS, DHCP, and NTP functionality Handles DNS and NTP functionality
""" """
import os import os
@@ -11,23 +11,24 @@ import subprocess
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
import requests
from base_service_manager import BaseServiceManager from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NetworkManager(BaseServiceManager): 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', def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
service_registry=None): service_registry=None):
super().__init__('network', data_dir, config_dir) super().__init__('network', data_dir, config_dir)
self.dns_zones_dir = os.path.join(data_dir, 'dns') 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 self._service_registry = service_registry
# Ensure directories exist # Ensure directories exist
self.safe_makedirs(self.dns_zones_dir) 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: def update_dns_zone(self, zone_name: str, records: List[Dict]) -> bool:
"""Update DNS zone file with new records""" """Update DNS zone file with new records"""
@@ -309,13 +310,137 @@ class NetworkManager(BaseServiceManager):
logger.error(f"Failed to list DNS records: {e}") logger.error(f"Failed to list DNS records: {e}")
return all_records 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]: def _load_dns_records(self, zone: str) -> List[Dict]:
"""Load DNS records from zone file""" """Load DNS records from zone file"""
zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone') zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone')
if not os.path.exists(zone_file): if not os.path.exists(zone_file):
return [] return []
records = [] records = []
try: try:
with open(zone_file, 'r') as f: with open(zone_file, 'r') as f:
@@ -344,80 +469,6 @@ class NetworkManager(BaseServiceManager):
return records 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: def get_ntp_status(self) -> Dict:
"""Get NTP service status""" """Get NTP service status"""
try: try:
@@ -460,36 +511,10 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
logger.error(f"Failed to reload DNS service: {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]: def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Write config to real service files and reload/restart affected containers.""" """Write config to real service files and reload/restart affected containers."""
restarted = [] restarted = []
warnings = [] 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 # NTP servers
if 'ntp_servers' in config and config['ntp_servers']: if 'ntp_servers' in config and config['ntp_servers']:
@@ -509,39 +534,17 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
warnings.append(f"ntp_servers write failed: {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} return {'restarted': restarted, 'warnings': warnings}
def apply_domain(self, domain: str, reload: bool = True) -> Dict[str, Any]: 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. reload=False writes config files only use when deferring container restart.
""" """
restarted = [] restarted = []
warnings = [] warnings = []
# 1. Update dnsmasq.conf domain= line # 1. Regenerate Corefile — include cell-to-cell forwarding stanzas so a
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
# domain/ip_range change doesn't wipe cross-cell DNS forwarding zones. # domain/ip_range change doesn't wipe cross-cell DNS forwarding zones.
try: try:
import firewall_manager as _fm import firewall_manager as _fm
@@ -562,7 +565,7 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
warnings.append(f"Corefile domain update failed: {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: try:
dns_data = os.path.join(self.data_dir, 'dns') dns_data = os.path.join(self.data_dir, 'dns')
if os.path.isdir(dns_data): if os.path.isdir(dns_data):
@@ -599,7 +602,7 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
warnings.append(f"zone file domain update failed: {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: if reload:
try: try:
self._reload_dns_service() self._reload_dns_service()
@@ -758,29 +761,6 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
return {'success': False, 'output': '', 'error': str(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: def test_ntp_functionality(self) -> Dict:
"""Test NTP functionality""" """Test NTP functionality"""
try: try:
@@ -879,19 +859,16 @@ class NetworkManager(BaseServiceManager):
if is_docker: if is_docker:
# Check if network containers are actually running # Check if network containers are actually running
dns_running = self._check_dns_container_status() dns_running = self._check_dns_container_status()
dhcp_running = self._check_dhcp_container_status()
ntp_running = self._check_ntp_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 = { status = {
'dns_running': dns_running, 'dns_running': dns_running,
'dhcp_running': dhcp_running,
'ntp_running': ntp_running, 'ntp_running': ntp_running,
'running': all_running, 'running': all_running,
'status': 'online' if all_running else 'offline', 'status': 'online' if all_running else 'offline',
'network': { 'network': {
'dns_running': dns_running, 'dns_running': dns_running,
'dhcp_running': dhcp_running,
'ntp_running': ntp_running, 'ntp_running': ntp_running,
'running': all_running, 'running': all_running,
'status': 'online' if all_running else 'offline' 'status': 'online' if all_running else 'offline'
@@ -901,25 +878,22 @@ class NetworkManager(BaseServiceManager):
else: else:
# Check actual service status in production # Check actual service status in production
dns_running = self._check_dns_status() dns_running = self._check_dns_status()
dhcp_running = self._check_dhcp_status()
ntp_running = self._check_ntp_status() ntp_running = self._check_ntp_status()
status = { status = {
'dns_running': dns_running, 'dns_running': dns_running,
'dhcp_running': dhcp_running,
'ntp_running': ntp_running, 'ntp_running': ntp_running,
'running': dns_running and dhcp_running and ntp_running, 'running': dns_running and ntp_running,
'status': 'online' if (dns_running and dhcp_running and ntp_running) else 'offline', 'status': 'online' if (dns_running and ntp_running) else 'offline',
'network': { 'network': {
'dns_running': dns_running, 'dns_running': dns_running,
'dhcp_running': dhcp_running,
'ntp_running': ntp_running, 'ntp_running': ntp_running,
'running': dns_running and dhcp_running and ntp_running, 'running': dns_running and ntp_running,
'status': 'online' if (dns_running and dhcp_running and ntp_running) else 'offline' 'status': 'online' if (dns_running and ntp_running) else 'offline'
}, },
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
} }
return status return status
except Exception as e: except Exception as e:
return self.handle_error(e, "get_status") return self.handle_error(e, "get_status")
@@ -934,16 +908,6 @@ class NetworkManager(BaseServiceManager):
except Exception: except Exception:
return False 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: def _check_ntp_container_status(self) -> bool:
"""Check if NTP Docker container is running""" """Check if NTP Docker container is running"""
try: try:
@@ -958,31 +922,28 @@ class NetworkManager(BaseServiceManager):
"""Test network service connectivity""" """Test network service connectivity"""
try: try:
dns_test = self.test_dns_resolution('google.com') dns_test = self.test_dns_resolution('google.com')
dhcp_test = self.test_dhcp_functionality()
ntp_test = self.test_ntp_functionality() ntp_test = self.test_ntp_functionality()
results = { results = {
'dns_test': dns_test, 'dns_test': dns_test,
'dhcp_test': dhcp_test,
'ntp_test': ntp_test, 'ntp_test': ntp_test,
'timestamp': datetime.utcnow().isoformat() 'timestamp': datetime.utcnow().isoformat()
} }
# Determine overall success # Determine overall success
success = all( success = all(
result.get('success', False) result.get('success', False)
for result in [dns_test, dhcp_test, ntp_test] for result in [dns_test, ntp_test]
) )
results['success'] = success results['success'] = success
# Add network key for compatibility # Add network key for compatibility
results['network'] = { results['network'] = {
'dns_test': dns_test, 'dns_test': dns_test,
'dhcp_test': dhcp_test,
'ntp_test': ntp_test, 'ntp_test': ntp_test,
'success': success 'success': success
} }
return results return results
except Exception as e: except Exception as e:
return self.handle_error(e, "test_connectivity") return self.handle_error(e, "test_connectivity")
@@ -1001,20 +962,6 @@ class NetworkManager(BaseServiceManager):
except Exception: except Exception:
return False 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: def _check_ntp_status(self) -> bool:
"""Check if NTP service is running""" """Check if NTP service is running"""
try: try:
+6 -34
View File
@@ -35,42 +35,14 @@ def remove_dns_record():
logger.error(f"Error removing DNS record: {e}") logger.error(f"Error removing DNS record: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route('/api/dhcp/leases', methods=['GET']) @bp.route('/api/dns/overview', methods=['GET'])
def get_dhcp_leases(): def get_dns_overview():
try: try:
from app import network_manager from app import network_manager, config_manager, ddns_manager
return jsonify(network_manager.get_dhcp_leases()) overview = network_manager.get_dns_overview(config_manager, ddns_manager)
return jsonify(overview)
except Exception as e: except Exception as e:
logger.error(f"Error getting DHCP leases: {e}") logger.error(f"Error getting DNS overview: {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}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@bp.route('/api/ntp/status', methods=['GET']) @bp.route('/api/ntp/status', methods=['GET'])
-1
View File
@@ -384,7 +384,6 @@ def get_all_services_status():
if service_name == 'network': if service_name == 'network':
clean_status.update({ clean_status.update({
'dns_status': status.get('dns_running', False), 'dns_status': status.get('dns_running', False),
'dhcp_status': status.get('dhcp_running', False),
'ntp_status': status.get('ntp_running', False) 'ntp_status': status.get('ntp_running', False)
}) })
elif service_name == 'wireguard': elif service_name == 'wireguard':
+18 -23
View File
@@ -14,6 +14,9 @@ services:
- ./data/caddy:/data - ./data/caddy:/data
- ./config/caddy/certs:/config/caddy/certs - ./config/caddy/certs:/config/caddy/certs
restart: unless-stopped restart: unless-stopped
mem_limit: 256m
cpus: 0.5
pids_limit: 256
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
networks: networks:
@@ -38,6 +41,9 @@ services:
- ./config/dns/Corefile:/etc/coredns/Corefile - ./config/dns/Corefile:/etc/coredns/Corefile
- ./data/dns:/data - ./data/dns:/data
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
cpus: 0.25
pids_limit: 256
networks: networks:
cell-network: cell-network:
ipv4_address: ${DNS_IP:-172.20.0.3} ipv4_address: ${DNS_IP:-172.20.0.3}
@@ -47,29 +53,6 @@ services:
max-size: "10m" max-size: "10m"
max-file: "5" 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 Server - chrony for time synchronization
ntp: ntp:
image: alpine:latest image: alpine:latest
@@ -80,6 +63,9 @@ services:
volumes: volumes:
- ./config/ntp/chrony.conf:/etc/chrony/chrony.conf - ./config/ntp/chrony.conf:/etc/chrony/chrony.conf
restart: unless-stopped restart: unless-stopped
mem_limit: 128m
cpus: 0.25
pids_limit: 256
networks: networks:
cell-network: cell-network:
ipv4_address: ${NTP_IP:-172.20.0.5} ipv4_address: ${NTP_IP:-172.20.0.5}
@@ -107,6 +93,9 @@ services:
- ./config/wireguard:/config - ./config/wireguard:/config
- /lib/modules:/lib/modules - /lib/modules:/lib/modules
restart: unless-stopped restart: unless-stopped
mem_limit: 256m
cpus: 0.5
pids_limit: 256
networks: networks:
cell-network: cell-network:
ipv4_address: ${WG_IP:-172.20.0.9} ipv4_address: ${WG_IP:-172.20.0.9}
@@ -149,6 +138,9 @@ services:
- ./scripts:/app/scripts:ro - ./scripts:/app/scripts:ro
pid: host pid: host
restart: unless-stopped restart: unless-stopped
mem_limit: 512m
cpus: 1.0
pids_limit: 256
networks: networks:
cell-network: cell-network:
ipv4_address: ${API_IP:-172.20.0.10} ipv4_address: ${API_IP:-172.20.0.10}
@@ -169,6 +161,9 @@ services:
ports: ports:
- "${WEBUI_PORT:-8081}:80" - "${WEBUI_PORT:-8081}:80"
restart: unless-stopped restart: unless-stopped
mem_limit: 256m
cpus: 0.5
pids_limit: 256
networks: networks:
cell-network: cell-network:
ipv4_address: ${WEBUI_IP:-172.20.0.11} ipv4_address: ${WEBUI_IP:-172.20.0.11}
-3
View File
@@ -17,13 +17,11 @@ import sys
REQUIRED_DIRS = [ REQUIRED_DIRS = [
'config/caddy/certs', 'config/caddy/certs',
'config/dns', 'config/dns',
'config/dhcp',
'config/ntp', 'config/ntp',
'config/wireguard', 'config/wireguard',
'config/api', 'config/api',
'data/caddy', 'data/caddy',
'data/dns', 'data/dns',
'data/dhcp',
'data/api', 'data/api',
'data/vault/certs', 'data/vault/certs',
'data/vault/keys', 'data/vault/keys',
@@ -37,7 +35,6 @@ REQUIRED_DIRS = [
REQUIRED_FILES = [ REQUIRED_FILES = [
'config/dns/Corefile', 'config/dns/Corefile',
'config/dhcp/dnsmasq.conf',
'config/ntp/chrony.conf', 'config/ntp/chrony.conf',
] ]
@@ -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)', () => { describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); }); beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); }); afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
-187
View File
@@ -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 (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection Info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`${proto}://${cellHost}`} />
<InfoRow label="CalDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="CardDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="Port" value={domain_mode === 'lan' ? '80' : '443'} />
<InfoRow label="Direct IP" value={calendarIp} />
<InfoRow label="Direct port" value={String(calendarPort)} />
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN connection. DNS server must be set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* iOS / Android quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">{cellHost}</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">{proto}://{cellHost}/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">{proto}://{cellHost}/</span></li>
</ol>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Calendar;
-174
View File
@@ -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 (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Incoming mail */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(imapPort)} />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={mailIp} />
</div>
</div>
{/* Outgoing mail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(smtpPort)} />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://mail.${svcDomain}`} />
<InfoRow label="Alt URL" value={`${proto}://webmail.${svcDomain}`} />
<InfoRow label="Direct IP" value={`http://${mailIp}`} />
<InfoRow label="Direct port" value={String(webmailPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.domain}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No email accounts configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Email;
-186
View File
@@ -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 (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${filesHost}`} />
<InfoRow label="Direct IP" value={`http://${filesIp}`} />
<InfoRow label="Direct port" value={String(filegatorPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Browser-based file manager. Requires VPN.
</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${webdavHost}`} />
<InfoRow label="Direct IP" value={`http://${webdavIp}`} />
<InfoRow label="Direct port" value={String(webdavPort)} />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* OS quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\{webdavHost}\DavWWWRoot</span> or use <span className="font-mono">{proto}://{webdavHost}</span> in "Connect to a Web Site"</p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
{users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export default Files;
+259 -138
View File
@@ -1,138 +1,259 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Network, Server, Clock } from 'lucide-react'; import { Network, Globe, Clock, RefreshCw, AlertTriangle } from 'lucide-react';
import { networkAPI, cellAPI } from '../services/api'; import { networkAPI, ddnsAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
const MODE_LABELS = {
function NetworkServices() { lan: 'LAN-only',
const { domain = 'cell' } = useConfig(); pic_ngo: 'pic.ngo DDNS',
const [dnsRecords, setDnsRecords] = useState([]); cloudflare: 'Cloudflare',
const [dhcpLeases, setDhcpLeases] = useState([]); custom: 'Custom registrar',
const [ntpStatus, setNtpStatus] = useState(null); duckdns: 'DuckDNS',
const [networkConfig, setNetworkConfig] = useState({}); };
const [isLoading, setIsLoading] = useState(true);
function statusBadge(status) {
useEffect(() => { if (status === 'registered') {
fetchNetworkData(); return <span className="text-xs px-2 py-0.5 rounded bg-success-50 text-success-700">registered</span>;
}, []); }
if (status === 'unregistered') {
const fetchNetworkData = async () => { return <span className="text-xs px-2 py-0.5 rounded bg-yellow-50 text-yellow-700">unregistered</span>;
try { }
const [dnsResponse, dhcpResponse, ntpResponse, cfgResponse] = await Promise.all([ return <span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-500">{status || 'unknown'}</span>;
networkAPI.getDNSRecords(), }
networkAPI.getDHCPLeases(),
networkAPI.getNTPStatus(), function RecordRow({ record }) {
cellAPI.getConfig(), return (
]); <div className="flex items-center justify-between p-2 bg-gray-50 rounded gap-2">
<div className="min-w-0">
setDnsRecords(dnsResponse.data); <span className="text-sm font-mono font-medium text-gray-800 break-all">{record.name}</span>
setDhcpLeases(dhcpResponse.data); <span className="text-xs text-gray-400 ml-2">{record.type}</span>
setNtpStatus(ntpResponse.data); </div>
setNetworkConfig(cfgResponse.data?.service_configs?.network || {}); <div className="flex items-center gap-2 shrink-0">
} catch (error) { <span className="text-sm font-mono text-gray-600">{record.value || '—'}</span>
console.error('Failed to fetch network data:', error); {record.status && statusBadge(record.status)}
} finally { </div>
setIsLoading(false); </div>
} );
}; }
if (isLoading) { function NetworkServices() {
return ( const [overview, setOverview] = useState(null);
<div className="flex items-center justify-center h-64"> const [ntpStatus, setNtpStatus] = useState(null);
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> const [isLoading, setIsLoading] = useState(true);
</div> const [actionBusy, setActionBusy] = useState(false);
); const [actionMsg, setActionMsg] = useState(null);
}
const fetchData = async () => {
return ( try {
<div> const [overviewResponse, ntpResponse] = await Promise.all([
<div className="mb-8"> networkAPI.getDNSOverview(),
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1> networkAPI.getNTPStatus(),
<p className="mt-2 text-gray-600"> ]);
DNS zone: <span className="font-mono font-medium text-gray-800">{domain}</span> setOverview(overviewResponse.data);
{networkConfig.dhcp_range && ( setNtpStatus(ntpResponse.data);
<> &middot; DHCP: <span className="font-mono font-medium text-gray-800">{networkConfig.dhcp_range}</span></> } catch (error) {
)} console.error('Failed to fetch network data:', error);
</p> } finally {
</div> setIsLoading(false);
}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> };
{/* DNS Records */}
<div className="card"> useEffect(() => {
<div className="flex items-center mb-4"> fetchData();
<Network className="h-6 w-6 text-primary-500 mr-2" /> }, []);
<h3 className="text-lg font-medium text-gray-900">DNS Records</h3>
</div> const runAction = async (fn, successMsg) => {
<div className="space-y-2"> setActionBusy(true);
{dnsRecords.length > 0 ? ( setActionMsg(null);
dnsRecords.map((record, index) => ( try {
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded"> await fn();
<div> setActionMsg({ type: 'success', text: successMsg });
<span className="text-sm font-medium">{record.name}</span> await fetchData();
<span className="text-xs text-gray-400 ml-1">.{record.zone}</span> } catch (error) {
</div> const text = error?.response?.data?.error || 'Action failed';
<span className="text-sm font-mono text-gray-600">{record.value}</span> setActionMsg({ type: 'error', text });
</div> } finally {
)) setActionBusy(false);
) : ( }
<p className="text-gray-500 text-sm">No DNS records configured</p> };
)}
</div> if (isLoading) {
</div> return (
<div className="flex items-center justify-center h-64">
{/* DHCP Leases */} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<div className="card"> </div>
<div className="flex items-center mb-4"> );
<Server className="h-6 w-6 text-primary-500 mr-2" /> }
<h3 className="text-lg font-medium text-gray-900">DHCP Leases</h3>
</div> if (!overview) {
{networkConfig.dhcp_range && ( return (
<p className="text-xs text-gray-400 mb-2">Range: {networkConfig.dhcp_range}</p> <div>
)} <h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
<div className="space-y-2"> <p className="mt-4 text-gray-500">Unable to load DNS overview.</p>
{dhcpLeases.length > 0 ? ( </div>
dhcpLeases.map((lease, index) => ( );
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded"> }
<span className="text-sm font-medium">{lease.hostname || 'Unknown'}</span>
<span className="text-sm text-gray-500">{lease.ip}</span> const mode = overview.mode;
</div> const modeLabel = MODE_LABELS[mode] || mode;
)) const ntpRunning = ntpStatus?.running === true;
) : (
<p className="text-gray-500 text-sm">No active DHCP leases</p> const renderAction = () => {
)} if (mode === 'pic_ngo' || mode === 'duckdns') {
</div> const label = mode === 'pic_ngo' ? 'Register / Update IP' : 'Update IP';
</div> return (
<button
{/* NTP Status */} className="btn-primary"
<div className="card"> disabled={actionBusy}
<div className="flex items-center mb-4"> onClick={() => runAction(ddnsAPI.register, 'IP registration triggered')}
<Clock className="h-6 w-6 text-primary-500 mr-2" /> >
<h3 className="text-lg font-medium text-gray-900">NTP Status</h3> <RefreshCw className="h-4 w-4 mr-1 inline" /> {label}
</div> </button>
{networkConfig.ntp_servers && ( );
<p className="text-xs text-gray-400 mb-2"> }
Servers: {Array.isArray(networkConfig.ntp_servers) if (mode === 'cloudflare') {
? networkConfig.ntp_servers.join(', ') return (
: networkConfig.ntp_servers} <button
</p> className="btn-primary"
)} disabled={actionBusy}
{ntpStatus ? ( onClick={() => runAction(ddnsAPI.syncRecords, 'DNS records synced')}
<div className="space-y-2"> >
<div className="flex justify-between"> <RefreshCw className="h-4 w-4 mr-1 inline" /> Sync now
<span className="text-sm text-gray-500">Status:</span> </button>
<span className="text-sm font-medium text-success-600">Online</span> );
</div> }
<div className="flex justify-between"> return null;
<span className="text-sm text-gray-500">Sync:</span> };
<span className="text-sm font-medium text-success-600">Synchronized</span>
</div> return (
</div> <div>
) : ( <div className="mb-8">
<p className="text-gray-500 text-sm">NTP service unavailable</p> <h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
)} <p className="mt-2 text-gray-600">
</div> Provider: <span className="font-medium text-gray-800">{modeLabel}</span>
</div> {overview.public_ip && (
</div> <> &middot; Public IP: <span className="font-mono font-medium text-gray-800">{overview.public_ip}</span></>
); )}
} </p>
</div>
export default NetworkServices;
{actionMsg && (
<div
className={`mb-4 p-3 rounded text-sm ${
actionMsg.type === 'success'
? 'bg-success-50 text-success-700'
: 'bg-red-50 text-red-700'
}`}
>
{actionMsg.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Public DNS */}
<div className="card">
<div className="flex items-center mb-4">
<Globe className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Public DNS</h3>
</div>
<p className="text-xs text-gray-400 mb-3">
<span className="font-mono">{overview.effective_domain}</span> &middot; {modeLabel}
</p>
{mode === 'lan' && (
<p className="text-sm text-gray-500">LAN-only, no public DNS.</p>
)}
{mode === 'custom' && (
<div className="mb-3 p-2 rounded bg-yellow-50 text-yellow-700 text-xs flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>Not managed by PIC. Create these records at your registrar.</span>
</div>
)}
{mode !== 'lan' && (
<div className="space-y-2">
{overview.public_records.length > 0 ? (
overview.public_records.map((record, index) => (
<RecordRow key={index} record={record} />
))
) : (
<p className="text-gray-500 text-sm">No public records.</p>
)}
</div>
)}
{(mode === 'pic_ngo' || mode === 'duckdns') && overview.service_subdomains.length > 0 && (
<p className="text-xs text-gray-400 mt-3">
Service subdomains resolve via the wildcard record.
</p>
)}
{renderAction() && <div className="mt-4">{renderAction()}</div>}
</div>
{/* Internal zone */}
<div className="card">
<div className="flex items-center mb-4">
<Network className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Internal zone</h3>
</div>
<p className="text-xs text-gray-400 mb-3">
<span className="font-mono">{overview.internal_domain}</span>
</p>
<div className="space-y-2">
{overview.internal_records.length > 0 ? (
overview.internal_records.map((record, index) => (
<RecordRow
key={index}
record={{
name: record.zone ? `${record.name}.${record.zone}` : record.name,
type: record.type,
value: record.value,
}}
/>
))
) : (
<p className="text-gray-500 text-sm">No internal records configured.</p>
)}
</div>
</div>
{/* NTP Status */}
<div className="card">
<div className="flex items-center mb-4">
<Clock className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">NTP Status</h3>
</div>
{ntpStatus ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Status:</span>
<span
className={`text-sm font-medium ${
ntpRunning ? 'text-success-600' : 'text-red-600'
}`}
>
{ntpRunning ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Sync:</span>
<span
className={`text-sm font-medium ${
ntpRunning ? 'text-success-600' : 'text-gray-500'
}`}
>
{ntpRunning ? 'Synchronized' : 'Unknown'}
</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">NTP service unavailable</p>
)}
</div>
</div>
</div>
);
}
export default NetworkServices;
+27 -24
View File
@@ -14,7 +14,7 @@ import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfi
const RESTORE_SERVICES = [ const RESTORE_SERVICES = [
{ key: 'identity', label: 'Identity (cell name, domain, IP range)' }, { 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: 'wireguard', label: 'WireGuard VPN' },
{ key: 'email', label: 'Email' }, { key: 'email', label: 'Email' },
{ key: 'calendar', label: 'Calendar & Contacts' }, { key: 'calendar', label: 'Calendar & Contacts' },
@@ -131,13 +131,6 @@ function validateServiceConfig(key, data) {
}; };
if (key === 'network') { if (key === 'network') {
port('dns_port'); 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)); const badNtp = (data.ntp_servers || []).find(s => !isValidDomainOrIp(s));
if (badNtp !== undefined) errors.ntp_servers = `"${badNtp}" is not a valid hostname or IP`; if (badNtp !== undefined) errors.ntp_servers = `"${badNtp}" is not a valid hostname or IP`;
} }
@@ -268,9 +261,6 @@ function NetworkForm({ data, onChange, errors = {} }) {
<Field label="DNS Port" error={errors.dns_port}> <Field label="DNS Port" error={errors.dns_port}>
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} /> <NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
</Field> </Field>
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h" error={errors.dhcp_range}>
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
</Field>
<Field label="NTP Servers" hint="Hostnames or IPs" error={errors.ntp_servers}> <Field label="NTP Servers" hint="Hostnames or IPs" error={errors.ntp_servers}>
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" /> <TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
</Field> </Field>
@@ -322,7 +312,7 @@ function VaultForm({ data, onChange }) {
// Service configs shown in Settings email/calendar/files moved to their own pages // Service configs shown in Settings email/calendar/files moved to their own pages
const SERVICE_DEFS = [ 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: '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: '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 } }, { key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } },
@@ -338,7 +328,7 @@ function Settings() {
// identity // identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' }); const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false); const [identityDirty, setIdentityDirty] = useState(false);
const [loadedCellName, setLoadedCellName] = useState(''); const [loadedIdentity, setLoadedIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [effectiveDomain, setEffectiveDomain] = useState(''); const [effectiveDomain, setEffectiveDomain] = useState('');
// DDNS // DDNS
@@ -385,12 +375,13 @@ function Settings() {
]); ]);
const cfg = cfgRes.data; const cfg = cfgRes.data;
if (certRes?.data) setCertStatus(certRes.data); if (certRes?.data) setCertStatus(certRes.data);
setIdentity({ const loadedIdent = {
cell_name: cfg.cell_name || '', cell_name: cfg.cell_name || '',
domain: cfg.domain || '', domain: cfg.domain || '',
ip_range: cfg.ip_range || '', ip_range: cfg.ip_range || '',
}); };
setLoadedCellName(cfg.cell_name || ''); setIdentity(loadedIdent);
setLoadedIdentity(loadedIdent);
setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || ''); setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || '');
setIdentityDirty(false); setIdentityDirty(false);
setDomainMode(cfg.domain_mode || 'lan'); setDomainMode(cfg.domain_mode || 'lan');
@@ -458,7 +449,7 @@ function Settings() {
if (domainMode !== 'pic_ngo') { setPicAvail(null); return; } if (domainMode !== 'pic_ngo') { setPicAvail(null); return; }
const name = identity.cell_name; const name = identity.cell_name;
// No check needed when the name hasn't changed from what's already registered. // 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); clearTimeout(picAvailTimerRef.current);
setPicAvail(null); setPicAvail(null);
picAvailTimerRef.current = setTimeout(async () => { picAvailTimerRef.current = setTimeout(async () => {
@@ -471,7 +462,19 @@ function Settings() {
} }
}, 900); }, 900);
return () => clearTimeout(picAvailTimerRef.current); 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 () => { const saveIdentity = useCallback(async () => {
if (ipRangeError || cellNameError || domainError) return; if (ipRangeError || cellNameError || domainError) return;
@@ -482,7 +485,7 @@ function Settings() {
try { try {
const res = await cellAPI.updateConfig(identity); const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false); setIdentityDirty(false);
setLoadedCellName(identity.cell_name); setLoadedIdentity(identity);
draftConfig?.setDirty('identity', false); draftConfig?.setDirty('identity', false);
if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning')); if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning'));
// Refresh to get updated domain_name after DDNS registration // Refresh to get updated domain_name after DDNS registration
@@ -651,10 +654,10 @@ function Settings() {
if (ipRangeError || cellNameError || domainError) return; if (ipRangeError || cellNameError || domainError) return;
// pic_ngo cell name changes require DDNS re-registration (irreversible: releases the // pic_ngo cell name changes require DDNS re-registration (irreversible: releases the
// old subdomain). Never auto-save these the user must explicitly press Accept. // 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); const timer = setTimeout(() => saveIdentityRef.current(), 800);
return () => clearTimeout(timer); 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(() => { useEffect(() => {
const timers = SERVICE_DEFS const timers = SERVICE_DEFS
@@ -802,7 +805,7 @@ function Settings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TextInput <TextInput
value={identity.cell_name} value={identity.cell_name}
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} onChange={(v) => updateIdentityField('cell_name', v)}
placeholder="mycell" placeholder="mycell"
maxLength={255} maxLength={255}
/> />
@@ -827,7 +830,7 @@ function Settings() {
<Field label="Local Domain" error={domainError}> <Field label="Local Domain" error={domainError}>
<TextInput <TextInput
value={identity.domain} value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} onChange={(v) => updateIdentityField('domain', v)}
placeholder="cell" placeholder="cell"
maxLength={255} maxLength={255}
/> />
@@ -846,7 +849,7 @@ function Settings() {
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}> <Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
<TextInput <TextInput
value={identity.ip_range} value={identity.ip_range}
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} onChange={(v) => updateIdentityField('ip_range', v)}
placeholder="172.20.0.0/16" placeholder="172.20.0.0/16"
/> />
</Field> </Field>