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
@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
View File
@@ -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."""
-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"""
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
}
},
+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',
})
+139 -192
View File
@@ -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,6 +310,130 @@ 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')
@@ -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,21 +878,18 @@ 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()
}
@@ -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,12 +922,10 @@ 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()
}
@@ -971,14 +933,13 @@ class NetworkManager(BaseServiceManager):
# Determine overall success
success = all(
result.get('success', False)
for result in [dns_test, dhcp_test, ntp_test]
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
}
@@ -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
View File
@@ -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'])
-1
View File
@@ -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
View File
@@ -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}
-3
View File
@@ -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(); });
-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;
+188 -67
View File
@@ -1,33 +1,55 @@
import { useState, useEffect } from 'react';
import { Network, Server, Clock } from 'lucide-react';
import { networkAPI, cellAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
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 { domain = 'cell' } = useConfig();
const [dnsRecords, setDnsRecords] = useState([]);
const [dhcpLeases, setDhcpLeases] = useState([]);
const [overview, setOverview] = useState(null);
const [ntpStatus, setNtpStatus] = useState(null);
const [networkConfig, setNetworkConfig] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [actionBusy, setActionBusy] = useState(false);
const [actionMsg, setActionMsg] = useState(null);
useEffect(() => {
fetchNetworkData();
}, []);
const fetchNetworkData = async () => {
const fetchData = async () => {
try {
const [dnsResponse, dhcpResponse, ntpResponse, cfgResponse] = await Promise.all([
networkAPI.getDNSRecords(),
networkAPI.getDHCPLeases(),
const [overviewResponse, ntpResponse] = await Promise.all([
networkAPI.getDNSOverview(),
networkAPI.getNTPStatus(),
cellAPI.getConfig(),
]);
setDnsRecords(dnsResponse.data);
setDhcpLeases(dhcpResponse.data);
setOverview(overviewResponse.data);
setNtpStatus(ntpResponse.data);
setNetworkConfig(cfgResponse.data?.service_configs?.network || {});
} catch (error) {
console.error('Failed to fetch network data:', error);
} finally {
@@ -35,6 +57,25 @@ function NetworkServices() {
}
};
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">
@@ -43,61 +84,136 @@ function NetworkServices() {
);
}
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">
DNS zone: <span className="font-mono font-medium text-gray-800">{domain}</span>
{networkConfig.dhcp_range && (
<> &middot; DHCP: <span className="font-mono font-medium text-gray-800">{networkConfig.dhcp_range}</span></>
Provider: <span className="font-medium text-gray-800">{modeLabel}</span>
{overview.public_ip && (
<> &middot; 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">
{/* DNS Records */}
{/* 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">DNS Records</h3>
<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">
{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>
{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 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>
<p className="text-gray-500 text-sm">No internal records configured.</p>
)}
</div>
</div>
@@ -108,22 +224,27 @@ function NetworkServices() {
<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>
<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 text-success-600">Synchronized</span>
<span
className={`text-sm font-medium ${
ntpRunning ? 'text-success-600' : 'text-gray-500'
}`}
>
{ntpRunning ? 'Synchronized' : 'Unknown'}
</span>
</div>
</div>
) : (
+27 -24
View File
@@ -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>