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:
@@ -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:"
|
||||
|
||||
+37
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+6
-34
@@ -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'])
|
||||
|
||||
@@ -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':
|
||||
|
||||
+18
-23
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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 & 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 & 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 & 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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 (
|
||||
<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">Network Services</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
DNS zone: <span className="font-mono font-medium text-gray-800">{domain}</span>
|
||||
{networkConfig.dhcp_range && (
|
||||
<> · DHCP: <span className="font-mono font-medium text-gray-800">{networkConfig.dhcp_range}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* DNS Records */}
|
||||
<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">DNS Records</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dnsRecords.length > 0 ? (
|
||||
dnsRecords.map((record, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{record.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-1">.{record.zone}</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-600">{record.value}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No DNS records configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DHCP Leases */}
|
||||
<div className="card">
|
||||
<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>
|
||||
{networkConfig.dhcp_range && (
|
||||
<p className="text-xs text-gray-400 mb-2">Range: {networkConfig.dhcp_range}</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{dhcpLeases.length > 0 ? (
|
||||
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>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No active DHCP leases</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>
|
||||
{networkConfig.ntp_servers && (
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Servers: {Array.isArray(networkConfig.ntp_servers)
|
||||
? networkConfig.ntp_servers.join(', ')
|
||||
: networkConfig.ntp_servers}
|
||||
</p>
|
||||
)}
|
||||
{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 text-success-600">Online</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Sync:</span>
|
||||
<span className="text-sm font-medium text-success-600">Synchronized</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">NTP service unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkServices;
|
||||
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 <span className="text-xs px-2 py-0.5 rounded bg-success-50 text-success-700">registered</span>;
|
||||
}
|
||||
if (status === 'unregistered') {
|
||||
return <span className="text-xs px-2 py-0.5 rounded bg-yellow-50 text-yellow-700">unregistered</span>;
|
||||
}
|
||||
return <span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-500">{status || 'unknown'}</span>;
|
||||
}
|
||||
|
||||
function RecordRow({ record }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-mono font-medium text-gray-800 break-all">{record.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">{record.type}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-sm font-mono text-gray-600">{record.value || '—'}</span>
|
||||
{record.status && statusBadge(record.status)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
|
||||
<p className="mt-4 text-gray-500">Unable to load DNS overview.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={actionBusy}
|
||||
onClick={() => runAction(ddnsAPI.register, 'IP registration triggered')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1 inline" /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (mode === 'cloudflare') {
|
||||
return (
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={actionBusy}
|
||||
onClick={() => runAction(ddnsAPI.syncRecords, 'DNS records synced')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1 inline" /> Sync now
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Provider: <span className="font-medium text-gray-800">{modeLabel}</span>
|
||||
{overview.public_ip && (
|
||||
<> · Public IP: <span className="font-mono font-medium text-gray-800">{overview.public_ip}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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> · {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;
|
||||
|
||||
@@ -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 = {} }) {
|
||||
<Field label="DNS Port" error={errors.dns_port}>
|
||||
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
|
||||
</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}>
|
||||
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
|
||||
</Field>
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
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"
|
||||
maxLength={255}
|
||||
/>
|
||||
@@ -827,7 +830,7 @@ function Settings() {
|
||||
<Field label="Local Domain" error={domainError}>
|
||||
<TextInput
|
||||
value={identity.domain}
|
||||
onChange={(v) => { 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() {
|
||||
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
|
||||
<TextInput
|
||||
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"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
Reference in New Issue
Block a user