9b5c2e1994
Unit Tests / test (push) Successful in 7m35s
Three related issues prevented CoreDNS from serving updated zone records: 1. The `file` plugin blocks in generate_corefile() lacked a `reload` option, so CoreDNS never re-read zone files after they were written. Added `reload 30s` so zone file changes are picked up within 30s. 2. _reload_dns_service() sent SIGHUP via `docker exec ... kill -HUP 1`, which doesn't trigger zone reloads. Changed to SIGUSR1 via `docker kill --signal=SIGUSR1` (same as firewall_manager.reload_coredns). 3. _bootstrap_dns() wrote the zone file but never regenerated the Corefile. CoreDNS's reload plugin only fires when the Corefile changes, so zone records from startup were invisible until the next peer modification triggered apply_all_dns_rules(). Now _bootstrap_dns() always calls apply_all_dns_rules() after the zone write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1030 lines
45 KiB
Python
1030 lines
45 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',
|
|
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"""
|
|
# 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 update_split_horizon_zone(self, effective_domain: str, caddy_ip: str,
|
|
primary_domain: str = 'cell',
|
|
peers: Optional[List[Dict]] = None,
|
|
cell_links: Optional[List[Dict]] = None) -> bool:
|
|
"""Write a local authoritative zone for effective_domain pointing all
|
|
hosts (wildcard) to caddy_ip so LAN clients resolve service subdomains
|
|
without hairpin NAT. Regenerates the Corefile and reloads CoreDNS."""
|
|
import firewall_manager as _fm
|
|
# SOA/NS are generated by _generate_zone_content; just pass the A records.
|
|
records = [
|
|
{'name': '@', 'type': 'A', 'value': caddy_ip},
|
|
{'name': '*', 'type': 'A', 'value': caddy_ip},
|
|
]
|
|
ok = self.update_dns_zone(effective_domain, records)
|
|
if not ok:
|
|
logger.warning('update_split_horizon_zone: zone file write failed for %s', effective_domain)
|
|
|
|
# Delete split-horizon zone files for prior cell names sharing the same TLD.
|
|
# E.g. when renaming from pic3.pic.ngo → pic2.pic.ngo, remove pic3.pic.ngo.zone.
|
|
eff_parts = effective_domain.split('.')
|
|
if len(eff_parts) >= 2:
|
|
tld_suffix = '.' + '.'.join(eff_parts[1:])
|
|
for fname in os.listdir(self.dns_zones_dir):
|
|
if fname.endswith('.zone'):
|
|
z = fname[:-5]
|
|
if z.endswith(tld_suffix) and z != effective_domain:
|
|
try:
|
|
os.remove(os.path.join(self.dns_zones_dir, fname))
|
|
logger.info('Deleted stale split-horizon zone: %s', fname)
|
|
except OSError as _e:
|
|
logger.warning('Failed to delete stale zone %s: %s', fname, _e)
|
|
|
|
# If the internal zone name happens to be a parent of the effective DDNS
|
|
# domain (e.g. primary_domain='pic.ngo', effective_domain='pic2.pic.ngo'),
|
|
# bootstrap service records like 'api', 'calendar' etc. would pollute the
|
|
# zone display and shadow the public domain. Remove them.
|
|
_stale = {'api', 'webui'} | set(self._BUILTIN_SERVICE_SUBDOMAINS) | set(self._get_service_subdomains())
|
|
if effective_domain.endswith('.' + primary_domain):
|
|
existing = self._load_dns_records(primary_domain)
|
|
cleaned = [r for r in existing if r.get('name', '') not in _stale]
|
|
if len(cleaned) < len(existing):
|
|
self.update_dns_zone(primary_domain, cleaned)
|
|
logger.info('Removed stale service records from zone %s', primary_domain)
|
|
|
|
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
|
|
peers_data = peers or []
|
|
ok_cf = _fm.generate_corefile(
|
|
peers_data, corefile, primary_domain,
|
|
cell_links=cell_links,
|
|
split_horizon_zones=[effective_domain],
|
|
)
|
|
if ok_cf:
|
|
_fm.reload_coredns()
|
|
return ok and ok_cf
|
|
|
|
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'
|
|
|
|
_SUBDOMAIN_RE = re.compile(r'^[a-z][a-z0-9-]{0,30}$')
|
|
|
|
def _get_service_subdomains(self) -> List[str]:
|
|
"""Return all service subdomains from the registry, or a hardcoded fallback."""
|
|
registry = getattr(self, "_service_registry", None)
|
|
if registry is not None:
|
|
try:
|
|
subs: List[str] = []
|
|
for route in registry.get_caddy_routes():
|
|
for sub in [route['subdomain']] + list(route.get('extra_subdomains') or []):
|
|
if self._SUBDOMAIN_RE.match(sub):
|
|
subs.append(sub)
|
|
else:
|
|
logger.warning('_get_service_subdomains: skipping invalid subdomain %r', sub)
|
|
return subs
|
|
except Exception as exc:
|
|
logger.warning('_get_service_subdomains: registry error: %s', exc)
|
|
return []
|
|
|
|
# Built-in service subdomains that are always present on a PIC instance.
|
|
# These must stay in sync with firewall_manager.SERVICE_IPS keys and the
|
|
# Caddy routes for each built-in service.
|
|
_BUILTIN_SERVICE_SUBDOMAINS = ('calendar', 'files', 'mail', 'webdav')
|
|
|
|
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()
|
|
records = [
|
|
{'name': cell_name, 'type': 'A', 'value': wg_ip},
|
|
{'name': 'api', 'type': 'A', 'value': wg_ip},
|
|
{'name': 'webui', 'type': 'A', 'value': wg_ip},
|
|
]
|
|
for sub in self._BUILTIN_SERVICE_SUBDOMAINS:
|
|
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
|
|
for sub in self._get_service_subdomains():
|
|
records.append({'name': sub, 'type': 'A', 'value': wg_ip})
|
|
return records
|
|
|
|
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):
|
|
"""Send SIGUSR1 to CoreDNS so the reload plugin picks up zone file changes."""
|
|
try:
|
|
subprocess.run(['docker', 'kill', '--signal=SIGUSR1', 'cell-dns'],
|
|
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}
|
|
# Exclude service names, wildcard, and apex from cell-hostname detection.
|
|
_service_names = {'api', 'webui'} | set(self._BUILTIN_SERVICE_SUBDOMAINS) | set(self._get_service_subdomains())
|
|
_reserved = _service_names | {'@', '*'}
|
|
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 not fname.endswith('.zone'):
|
|
continue
|
|
zone_name = fname[:-5]
|
|
# Skip split-horizon DDNS zones (multi-label, e.g. 'pic2.pic.ngo.zone')
|
|
# and any zone with 'local' in its name. The cell hostname only lives
|
|
# in the primary single-label zone (e.g. 'cell.zone').
|
|
if 'local' in zone_name or '.' in zone_name:
|
|
continue
|
|
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 _reserved)
|
|
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 _reserved:
|
|
actual_old = candidate
|
|
break
|
|
if actual_old is None:
|
|
continue # no hostname in this zone; try next
|
|
if actual_old == new_name:
|
|
break # already correct
|
|
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 |