Files
pic/api/network_manager.py
roof c2d215ee2e fix: cross-cell routing for split-tunnel peers
Three related fixes for split-tunnel peers that need to reach connected cells:

1. apply_peer_rules/apply_all_peer_rules now accept wg_subnet (actual local VPN
   subnet) and cell_subnets (connected cells' vpn_subnets) parameters instead of
   hardcoding 10.0.0.0/24. All callers (startup, add_peer, update_peer,
   apply-enforcement endpoint) pass the real values.

2. Explicit ACCEPT rules are inserted in FORWARD for each connected cell's
   subnet so split-tunnel peers (internet_access=False) can still reach
   connected cells via the wg0→wg0 path.

3. apply_ip_range in network_manager now loads cell_links.json and passes it
   to generate_corefile(), fixing a race where the bootstrap DNS thread could
   overwrite the Corefile and wipe cross-cell DNS forwarding zones on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:36:28 -04:00

938 lines
40 KiB
Python

#!/usr/bin/env python3
"""
Network Manager for Personal Internet Cell
Handles DNS, DHCP, and NTP functionality
"""
import os
import re
import json
import subprocess
import logging
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__)
class NetworkManager(BaseServiceManager):
"""Manages network services (DNS, DHCP, NTP)"""
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
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')
# 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"""
# Validate zone_name — must be a safe DNS label, no path traversal
if not isinstance(zone_name, str) or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$', zone_name):
logger.error(f"update_dns_zone: invalid zone_name {zone_name!r}")
return False
try:
zone_file = os.path.join(self.dns_zones_dir, f'{zone_name}.zone')
# Containment check: resolved zone_file must be inside dns_zones_dir
real_dir = os.path.realpath(self.dns_zones_dir)
real_zone = os.path.realpath(zone_file)
if not (real_zone == real_dir or real_zone.startswith(real_dir + os.sep)):
logger.error(f"update_dns_zone: path traversal attempt for zone {zone_name!r}")
return False
# Validate every record's name and value to prevent zone-file injection
for rec in records:
rname = rec.get('name', '')
rvalue = rec.get('value', '')
if rname and not re.match(r'^[a-zA-Z0-9_.*-]{1,253}$', str(rname)):
logger.error(f"update_dns_zone: invalid record name {rname!r}")
return False
if rvalue and not re.match(r'^[a-zA-Z0-9._: -]{1,512}$', str(rvalue)):
logger.error(f"update_dns_zone: invalid record value {rvalue!r}")
return False
# Create zone file content
content = self._generate_zone_content(zone_name, records)
tmp_file = zone_file + '.tmp'
with open(tmp_file, 'w') as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_file, zone_file)
# Reload DNS service
self._reload_dns_service()
logger.info(f"Updated DNS zone {zone_name} with {len(records)} records")
return True
except Exception as e:
logger.error(f"Failed to update DNS zone {zone_name}: {e}")
return False
def _generate_zone_content(self, zone_name: str, records: List[Dict]) -> str:
"""Generate DNS zone file content"""
timestamp = datetime.now().strftime('%Y%m%d%H')
content = f"""$TTL 3600
@ IN SOA {zone_name}. admin.{zone_name}. (
{timestamp} ; Serial
3600 ; Refresh
1800 ; Retry
1209600 ; Expire
3600 ; Minimum TTL
)
; Name servers
@ IN NS {zone_name}.
"""
# Add records
for record in records:
record_type = record.get('type', 'A')
name = record.get('name', '')
value = record.get('value', '')
ttl = record.get('ttl', '3600')
if name and value:
content += f"{name:<20} {ttl:<8} IN {record_type:<6} {value}\n"
return content
def add_dns_record(self, zone: str, name: str, record_type: str, value: str, ttl: int = 3600) -> bool:
"""Add a DNS record to a zone"""
# Validate zone, name, and value to prevent injection / path traversal
if not isinstance(zone, str) or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$', zone):
logger.error(f"add_dns_record: invalid zone {zone!r}")
return False
if not isinstance(name, str) or not re.match(r'^[a-zA-Z0-9_.*-]{1,253}$', name):
logger.error(f"add_dns_record: invalid name {name!r}")
return False
if not isinstance(value, str) or not re.match(r'^[a-zA-Z0-9._: -]{1,512}$', value):
logger.error(f"add_dns_record: invalid value {value!r}")
return False
try:
# Load existing records
records = self._load_dns_records(zone)
# Add new record
new_record = {
'name': name,
'type': record_type,
'value': value,
'ttl': ttl
}
# Remove existing record with same name and type
records = [r for r in records if not (r['name'] == name and r['type'] == record_type)]
records.append(new_record)
# Update zone
return self.update_dns_zone(zone, records)
except Exception as e:
logger.error(f"Failed to add DNS record: {e}")
return False
def remove_dns_record(self, zone: str, name: str, record_type: str = 'A') -> bool:
"""Remove a DNS record from a zone"""
try:
# Load existing records
records = self._load_dns_records(zone)
# Remove matching records
records = [r for r in records if not (r['name'] == name and r['type'] == record_type)]
# Update zone
return self.update_dns_zone(zone, records)
except Exception as e:
logger.error(f"Failed to remove DNS record: {e}")
return False
def bootstrap_dns_records(self, cell_name: str, domain: str,
ip_range: str = '172.20.0.0/16') -> None:
"""Create default service A records the first time the cell starts up.
Skipped if a zone file already exists (idempotent)."""
zone_file = os.path.join(self.dns_zones_dir, f'{domain}.zone')
if os.path.exists(zone_file):
return
logger.info(f"Bootstrapping DNS records for zone '{domain}'")
records = self._build_dns_records(cell_name, ip_range)
self.update_dns_zone(domain, records)
logger.info(f"Created {len(records)} default DNS records for zone '{domain}'")
def apply_ip_range(self, ip_range: str, cell_name: str, domain: str) -> Dict[str, Any]:
"""Rewrite the primary DNS zone file with IPs derived from the new subnet."""
restarted: List[str] = []
warnings: List[str] = []
try:
records = self._build_dns_records(cell_name, ip_range)
if self.update_dns_zone(domain, records):
restarted.append('cell-dns (reloaded)')
else:
warnings.append('DNS zone update failed')
except Exception as e:
warnings.append(f'apply_ip_range failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def _get_wg_server_ip(self) -> str:
"""Return the WireGuard server IP by reading wg0.conf. Falls back to 10.0.0.1."""
try:
import ipaddress
conf = os.path.join(self.config_dir, 'wireguard', 'wg_confs', 'wg0.conf')
with open(conf) as f:
for line in f:
stripped = line.strip()
if stripped.lower().startswith('address'):
addr = stripped.split('=', 1)[1].strip()
return str(ipaddress.ip_interface(addr).ip)
except Exception:
pass
return '10.0.0.1'
def _build_dns_records(self, cell_name: str, ip_range: str) -> List[Dict]:
"""Build the standard set of DNS A records.
All service names resolve to the WG server IP so they are reachable
from both local WG peers and cross-cell peers without Docker bridge
subnet conflicts. ensure_service_dnat() routes wg0:80 to Caddy, which
routes requests to the correct backend by Host header.
"""
wg_ip = self._get_wg_server_ip()
return [
{'name': cell_name, 'type': 'A', 'value': wg_ip},
{'name': 'api', 'type': 'A', 'value': wg_ip},
{'name': 'webui', 'type': 'A', 'value': wg_ip},
{'name': 'calendar', 'type': 'A', 'value': wg_ip},
{'name': 'files', 'type': 'A', 'value': wg_ip},
{'name': 'mail', 'type': 'A', 'value': wg_ip},
{'name': 'webmail', 'type': 'A', 'value': wg_ip},
{'name': 'webdav', 'type': 'A', 'value': wg_ip},
]
def get_dns_records(self, zone: str = 'cell') -> List[Dict]:
"""Get all DNS records across all zones"""
all_records = []
try:
for fname in os.listdir(self.dns_zones_dir):
if fname.endswith('.zone'):
z = fname[:-5]
for rec in self._load_dns_records(z):
rec['zone'] = z
all_records.append(rec)
except Exception as e:
logger.error(f"Failed to list DNS records: {e}")
return all_records
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:
lines = f.readlines()
for line in lines:
line = line.strip().split(';')[0].strip() # strip inline comments
if not line or line.startswith('$'):
continue
parts = line.split()
# Support both: name IN type value (4 parts)
# and name TTL IN type value (5 parts)
if len(parts) == 4 and parts[1] in ('IN',) and parts[2] in ('A', 'CNAME', 'MX', 'TXT'):
records.append({'name': parts[0], 'ttl': '300', 'type': parts[2], 'value': parts[3]})
elif len(parts) >= 5:
record_type = parts[3]
if record_type in ('A', 'CNAME'):
records.append({
'name': parts[0],
'ttl': parts[1],
'type': record_type,
'value': parts[4]
})
except Exception as e:
logger.error(f"Failed to load DNS records: {e}")
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:
# Check if NTP service is running
result = subprocess.run(['docker', 'ps', '--filter', 'name=cell-ntp', '--format', '{{.Names}}'],
capture_output=True, text=True)
is_running = len(result.stdout.strip()) > 0
# Get NTP statistics if running
stats = {}
if is_running:
try:
result = subprocess.run(['docker', 'exec', 'cell-ntp', 'chronyc', 'tracking'],
capture_output=True, text=True)
if result.returncode == 0:
stats['tracking'] = result.stdout
result = subprocess.run(['docker', 'exec', 'cell-ntp', 'chronyc', 'sources'],
capture_output=True, text=True)
if result.returncode == 0:
stats['sources'] = result.stdout
except Exception as e:
logger.error(f"Failed to get NTP stats: {e}")
return {
'running': is_running,
'stats': stats
}
except Exception as e:
logger.error(f"Failed to get NTP status: {e}")
return {'running': False, 'stats': {}}
def _reload_dns_service(self):
"""Reload DNS service"""
try:
subprocess.run(['docker', 'exec', 'cell-dns', 'kill', '-HUP', '1'],
capture_output=True, timeout=10)
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']:
try:
ntp_conf = os.path.join(self.config_dir, 'ntp', 'chrony.conf')
if os.path.exists(ntp_conf):
with open(ntp_conf) as f:
lines = f.readlines()
# Remove existing server lines, add new ones
lines = [l for l in lines if not l.startswith('server ')]
new_servers = [f"server {s} iburst\n" for s in config['ntp_servers']]
lines = new_servers + lines
with open(ntp_conf, 'w') as f:
f.writelines(lines)
self._restart_container('cell-ntp')
restarted.append('cell-ntp')
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.
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
# domain/ip_range change doesn't wipe cross-cell DNS forwarding zones.
try:
import firewall_manager as _fm
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
peers_file = os.path.join(self.data_dir, 'peers.json')
try:
import json as _json
peers = _json.loads(open(peers_file).read()) if os.path.exists(peers_file) else []
except Exception:
peers = []
cell_links_file = os.path.join(self.data_dir, 'cell_links.json')
try:
import json as _json2
cell_links = _json2.loads(open(cell_links_file).read()) if os.path.exists(cell_links_file) else []
except Exception:
cell_links = []
_fm.generate_corefile(peers, corefile, domain, cell_links=cell_links)
except Exception as e:
warnings.append(f"Corefile domain update failed: {e}")
# 3. 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):
dst = os.path.join(dns_data, f'{domain}.zone')
zone_files = [
os.path.join(dns_data, f)
for f in os.listdir(dns_data)
if f.endswith('.zone') and 'local' not in f
]
src = next((p for p in zone_files if p != dst), dst)
if os.path.exists(src):
with open(src) as f:
zone_content = f.read()
# Try $ORIGIN first, then fall back to SOA MNAME
m = re.search(r'^\$ORIGIN\s+(\S+)', zone_content, re.MULTILINE)
if m:
old_origin = m.group(1).rstrip('.')
else:
m2 = re.search(r'^@\s+IN\s+SOA\s+(\S+?)\.?\s', zone_content, re.MULTILINE)
old_origin = m2.group(1).rstrip('.') if m2 else None
if old_origin and old_origin != domain:
zone_content = zone_content.replace(f'{old_origin}.', f'{domain}.')
if re.search(r'^\$ORIGIN\s+', zone_content, re.MULTILINE):
zone_content = re.sub(
r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE)
with open(dst, 'w') as f:
f.write(zone_content)
for zone_path in zone_files:
if zone_path != dst:
try:
os.remove(zone_path)
except OSError:
pass
except Exception as e:
warnings.append(f"zone file domain update failed: {e}")
# 4. Reload CoreDNS (only when not deferring to Apply)
if reload:
try:
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f"CoreDNS reload failed: {e}")
return {'restarted': restarted, 'warnings': warnings}
def apply_cell_name(self, old_name: str, new_name: str, reload: bool = True) -> Dict[str, Any]:
"""Update the cell hostname record in the primary DNS zone file.
reload=False writes the zone file only — use when deferring container restart.
old_name is a hint; if it's absent from the zone file, we detect the actual
hostname by finding the non-service A record pointing to the Caddy IP.
"""
restarted = []
warnings = []
if not new_name:
return {'restarted': restarted, 'warnings': warnings}
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
changed = False
try:
dns_data = os.path.join(self.data_dir, 'dns')
if os.path.isdir(dns_data):
for fname in os.listdir(dns_data):
if fname.endswith('.zone') and 'local' not in fname:
zone_file = os.path.join(dns_data, fname)
with open(zone_file) as f:
content = f.read()
# Determine which name to replace: prefer old_name if present,
# otherwise detect from zone (non-service A record not in _service_names)
actual_old = old_name if (
old_name and re.search(
rf'^{re.escape(old_name)}\s', content, re.MULTILINE)
) else None
if actual_old is None:
for m in re.finditer(
r'^(\S+)\s+(?:\d+\s+)?IN\s+A\s+\S+', content, re.MULTILINE
):
candidate = m.group(1)
if candidate not in _service_names and candidate != '@':
actual_old = candidate
break
if actual_old is None or actual_old == new_name:
break
new_content = re.sub(
rf'^{re.escape(actual_old)}(\s+(?:\d+\s+)?IN\s+A\s+)',
f'{new_name}\\1',
content, flags=re.MULTILINE
)
if new_content != content:
with open(zone_file, 'w') as f:
f.write(new_content)
changed = True
break
if changed and reload:
self._reload_dns_service()
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f"cell_name DNS update failed: {e}")
return {'restarted': restarted, 'warnings': warnings}
def _load_cell_links(self) -> List[Dict[str, Any]]:
"""Load cell_links.json from the data directory (written by CellLinkManager)."""
links_file = os.path.join(self.data_dir, 'cell_links.json')
if os.path.exists(links_file):
try:
with open(links_file) as f:
return json.load(f)
except Exception:
return []
return []
def add_cell_dns_forward(self, domain: str, dns_ip: str) -> Dict[str, Any]:
"""Register a CoreDNS forwarding entry for a remote cell's domain.
Validates inputs, then rebuilds the entire Corefile via
firewall_manager.apply_all_dns_rules() so that no existing stanza is
silently wiped. Does NOT write the Corefile directly.
"""
import ipaddress
import firewall_manager as fm
restarted = []
warnings = []
# Validate dns_ip — newlines/garbage would inject arbitrary CoreDNS directives
try:
ipaddress.ip_address(dns_ip)
except (ValueError, TypeError):
warnings.append(f'add_cell_dns_forward: invalid dns_ip {dns_ip!r}')
return {'restarted': restarted, 'warnings': warnings}
# Validate domain — reject newlines, braces, spaces, and any non-DNS chars
if (not isinstance(domain, str)
or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$', domain)
or any(c in domain for c in ('\n', '\r', '{', '}', ' ', '\t'))):
warnings.append(f'add_cell_dns_forward: invalid domain {domain!r}')
return {'restarted': restarted, 'warnings': warnings}
try:
# Build the full forwarding list: existing links + new entry (deduped by domain)
existing_links = self._load_cell_links()
# The new entry may not yet be in cell_links.json (CellLinkManager saves after
# calling us), so we merge it in here.
merged = [l for l in existing_links if l.get('domain') != domain]
merged.append({'domain': domain, 'dns_ip': dns_ip})
corefile_path = os.path.join(self.config_dir, 'dns', 'Corefile')
# Peers list is empty here; the full peer list is used by the periodic
# apply_all_dns_rules() call from app.py. We only need to persist the
# forwarding stanza without disturbing whatever peer ACLs are in the file.
fm.apply_all_dns_rules([], corefile_path=corefile_path, cell_links=merged)
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f'add_cell_dns_forward failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def remove_cell_dns_forward(self, domain: str) -> Dict[str, Any]:
"""Unregister a CoreDNS forwarding entry for a remote cell's domain.
Rebuilds the entire Corefile via firewall_manager.apply_all_dns_rules()
with the named domain excluded. Does NOT write the Corefile directly.
"""
import firewall_manager as fm
restarted = []
warnings = []
try:
existing_links = self._load_cell_links()
# Exclude the domain being removed; CellLinkManager will also remove it
# from cell_links.json after this call returns.
remaining = [l for l in existing_links if l.get('domain') != domain]
corefile_path = os.path.join(self.config_dir, 'dns', 'Corefile')
fm.apply_all_dns_rules([], corefile_path=corefile_path, cell_links=remaining)
restarted.append('cell-dns (reloaded)')
except Exception as e:
warnings.append(f'remove_cell_dns_forward failed: {e}')
return {'restarted': restarted, 'warnings': warnings}
def test_dns_resolution(self, domain: str) -> Dict:
"""Test DNS resolution for a domain using Python socket."""
import socket
try:
results = socket.getaddrinfo(domain, None)
addrs = [r[4][0] for r in results]
return {'success': True, 'output': f"Resolved: {', '.join(addrs)}", 'error': ''}
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:
# Check if NTP service is running
result = subprocess.run(['docker', 'ps', '--filter', 'name=cell-ntp', '--format', '{{.Names}}'],
capture_output=True, text=True)
is_running = len(result.stdout.strip()) > 0
# Test NTP query
ntp_test = {}
if is_running:
try:
result = subprocess.run(['docker', 'exec', 'cell-ntp', 'chronyc', 'tracking'],
capture_output=True, text=True, timeout=10)
ntp_test['tracking'] = result.returncode == 0
ntp_test['output'] = result.stdout
except Exception as e:
ntp_test['tracking'] = False
ntp_test['error'] = str(e)
return {
'success': is_running,
'running': is_running,
'ntp_test': ntp_test
}
except Exception as e:
logger.error(f"Failed to test NTP functionality: {e}")
return {'success': False, 'running': False, 'ntp_test': {}}
def get_network_info(self) -> dict:
"""Return general network info: IP addresses, interfaces, gateway, DNS, etc."""
try:
info = {}
# Get network interfaces
result = subprocess.run(['ip', '-j', 'addr'], capture_output=True, text=True)
if result.returncode == 0:
import json as _json
info['interfaces'] = _json.loads(result.stdout)
else:
info['interfaces'] = []
# Get default gateway
result = subprocess.run(['ip', 'route', 'show', 'default'], capture_output=True, text=True)
if result.returncode == 0:
info['default_gateway'] = result.stdout.strip()
else:
info['default_gateway'] = ''
# Get DNS servers
resolv_conf = '/etc/resolv.conf'
dns_servers = []
try:
with open(resolv_conf, 'r') as f:
for line in f:
if line.startswith('nameserver'):
dns_servers.append(line.strip().split()[1])
except Exception:
pass
info['dns_servers'] = dns_servers
return info
except Exception as e:
logger.error(f"Failed to get network info: {e}")
return {'error': str(e)}
def get_dns_status(self) -> dict:
"""Return DNS service status and summary info."""
try:
# Check if DNS service is running
result = subprocess.run(['docker', 'ps', '--filter', 'name=cell-dns', '--format', '{{.Names}}'], capture_output=True, text=True)
is_running = len(result.stdout.strip()) > 0
# Get DNS records count (for all zones)
records_count = 0
try:
for fname in os.listdir(self.dns_zones_dir):
if fname.endswith('.zone'):
with open(os.path.join(self.dns_zones_dir, fname), 'r') as f:
for line in f:
if line.strip() and not line.startswith(';') and not line.startswith('$'):
parts = line.split()
if len(parts) >= 5 and parts[3] in ('A', 'CNAME'):
records_count += 1
except Exception:
pass
return {'running': is_running, 'records_count': records_count}
except Exception as e:
logger.error(f"Failed to get DNS status: {e}")
return {'running': False, 'records_count': 0, 'error': str(e)}
def get_status(self) -> Dict[str, Any]:
"""Get network service status"""
try:
# Check if we're running in Docker environment
import os
is_docker = os.path.exists('/.dockerenv') or os.environ.get('DOCKER_CONTAINER') == 'true'
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
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'
},
'timestamp': datetime.utcnow().isoformat()
}
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',
'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'
},
'timestamp': datetime.utcnow().isoformat()
}
return status
except Exception as e:
return self.handle_error(e, "get_status")
def _check_dns_container_status(self) -> bool:
"""Check if DNS Docker container is running"""
try:
import docker
client = docker.from_env()
containers = client.containers.list(filters={'name': 'cell-dns'})
return len(containers) > 0
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:
import docker
client = docker.from_env()
containers = client.containers.list(filters={'name': 'cell-ntp'})
return len(containers) > 0
except Exception:
return False
def test_connectivity(self) -> Dict[str, Any]:
"""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]
)
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")
def _check_dns_status(self) -> bool:
"""Check if DNS service is running"""
try:
result = subprocess.run(['systemctl', 'is-active', 'coredns'],
capture_output=True, text=True, timeout=5)
return result.returncode == 0 and result.stdout.strip() == 'active'
except Exception:
# Fallback: check if port 53 is listening
try:
result = subprocess.run(['netstat', '-tuln'], capture_output=True, text=True)
return ':53 ' in result.stdout
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:
result = subprocess.run(['systemctl', 'is-active', 'chronyd'],
capture_output=True, text=True, timeout=5)
return result.returncode == 0 and result.stdout.strip() == 'active'
except Exception:
# Fallback: check if port 123 is listening
try:
result = subprocess.run(['netstat', '-tuln'], capture_output=True, text=True)
return ':123 ' in result.stdout
except Exception:
return False