Files
pic/api/config_manager.py
T
roof 3d750ed1e8
Unit Tests / test (push) Successful in 7m23s
Fix DDNS security and reliability gaps (#2, #3, #5, #6, #7)
- Fix #2: Move DDNS bearer token from cell_config.json to data/api/ddns_token.
  Token is now in the secrets store (data/) rather than the config store (config/).
  Auto-migrates existing installs on first access. ConfigManager.get/set_ddns_token()
  added. set_ddns_config() now strips 'token' key to prevent it leaking back.

- Fix #3: Set Caddyfile permissions to 0o600 after write so the token embedded
  in the Caddyfile is not world-readable on the host filesystem.

- Fix #5: Heartbeat now fires IDENTITY_CHANGED after re-registration so Caddy
  regenerates its config with the new token automatically — users no longer need
  to click Re-register in Settings after a wizard registration failure.
  Also: heartbeat skips the 401-cycle when no token exists and goes straight to
  registration instead. DDNSManager now accepts service_bus= and is wired up.

- Fix #6: Settings page starts polling GET /api/caddy/cert-status every 15s
  after a successful DDNS re-registration and shows "Acquiring certificate…"
  feedback until Let's Encrypt issues the cert (up to 5 minutes).

- Fix #7: regenerate_with_installed() is debounced (5 s window) so two rapid
  IDENTITY_CHANGED events (e.g. wizard + heartbeat) can't start simultaneous
  ACME orders that interfere with each other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 03:37:48 -04:00

763 lines
33 KiB
Python

#!/usr/bin/env python3
"""
Configuration Manager for Personal Internet Cell
Centralized configuration management for all services
"""
import os
import json
import re
import subprocess
import yaml
import shutil
import hashlib
from datetime import datetime
from typing import Dict, List, Optional, Any
from pathlib import Path
import logging
_SAFE_CONTAINER_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$')
_SAFE_VOL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{1,64}$')
# The Caddyfile lives on a separate volume mount from the rest of config
LIVE_CADDYFILE = os.environ.get('CADDYFILE_PATH', '/app/config-caddy/Caddyfile')
logger = logging.getLogger(__name__)
class ConfigManager:
"""Centralized configuration management for all services (unified config)"""
def __init__(self, config_file: str = '/app/config/cell_config.json', data_dir: str = '/app/data'):
config_file = Path(config_file)
if config_file.is_dir():
config_file = config_file / 'cell_config.json'
print(f"[DEBUG] ConfigManager.__init__: config_file = {config_file}")
self.config_file = config_file
self.data_dir = Path(data_dir)
self.backup_dir = self.data_dir / 'config_backups'
self.secrets_file = self.config_file.parent / 'secrets.yaml'
try:
self.backup_dir.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError):
pass
self.service_schemas = self._load_service_schemas()
self.configs = self._load_all_configs()
# Ensure _identity key always exists
if '_identity' not in self.configs:
self.configs['_identity'] = {}
# Phase 5: ensure connectivity section exists with empty defaults.
if 'connectivity' not in self.configs:
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
if not self.config_file.exists():
self._save_all_configs()
# Silent migration: when DDNS is active but the internal domain is still
# the generic "cell" default, give CoreDNS a unique zone name so multiple
# cells on the same LAN don't collide.
try:
_ident = self.configs.get('_identity', {})
_mode = _ident.get('domain_mode', 'lan')
_domain = _ident.get('domain', '')
_cell_name = _ident.get('cell_name', '')
if (_mode != 'lan' and _cell_name
and (_domain in ('cell', '', None))):
_new_domain = f'{_cell_name}.local'
self.configs['_identity']['domain'] = _new_domain
self._save_all_configs()
except Exception:
pass
def _load_service_schemas(self) -> Dict[str, Dict]:
"""Load configuration schemas for all services"""
return {
'network': {
'required': ['dns_port', 'dhcp_range', 'ntp_servers'],
'optional': ['dns_zones', 'dhcp_reservations'],
'types': {
'dns_port': int,
'dhcp_range': str,
'ntp_servers': list
}
},
'wireguard': {
'required': ['port', 'private_key', 'address'],
'optional': ['peers', 'allowed_ips'],
'types': {
'port': int,
'private_key': str,
'address': str
}
},
'email': {
'required': ['domain', 'smtp_port', 'imap_port'],
'optional': ['users', 'ssl_cert', 'ssl_key', 'submission_port', 'webmail_port'],
'types': {
'smtp_port': int,
'submission_port': int,
'imap_port': int,
'webmail_port': int,
'domain': str
}
},
'calendar': {
'required': ['port', 'data_dir'],
'optional': ['users', 'calendars'],
'types': {
'port': int,
'data_dir': str
}
},
'files': {
'required': ['port', 'data_dir'],
'optional': ['users', 'quota', 'manager_port'],
'types': {
'port': int,
'manager_port': int,
'data_dir': str,
'quota': int
}
},
'routing': {
'required': ['nat_enabled', 'firewall_enabled'],
'optional': ['nat_rules', 'firewall_rules', 'peer_routes'],
'types': {
'nat_enabled': bool,
'firewall_enabled': bool
}
},
'vault': {
'required': ['ca_configured', 'fernet_configured'],
'optional': ['certificates', 'trusted_keys'],
'types': {
'ca_configured': bool,
'fernet_configured': bool
}
},
'connectivity': {
'required': [],
'optional': ['exits', 'peer_exit_map'],
'types': {
'exits': dict,
'peer_exit_map': dict,
}
}
}
def _load_all_configs(self) -> Dict[str, Dict]:
"""Load all existing service configurations"""
if self.config_file.exists():
try:
with open(self.config_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading unified config: {e}")
return {}
return {}
def _save_all_configs(self):
"""Save all service configurations to the unified config file (atomic write)."""
try:
self.config_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.config_file.with_suffix('.tmp')
with open(tmp, 'w') as f:
json.dump(self.configs, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self.config_file)
except (PermissionError, OSError) as e:
logger.error('_save_all_configs: write failed — config NOT persisted to disk: %s', e)
def get_service_config(self, service: str) -> Dict[str, Any]:
"""Get configuration for a specific service"""
if service not in self.service_schemas:
raise ValueError(f"Unknown service: {service}")
return self.configs.get(service, {})
def update_service_config(self, service: str, config: Dict[str, Any]) -> bool:
"""Update configuration for a specific service"""
if service not in self.service_schemas:
raise ValueError(f"Unknown service: {service}")
try:
# Validate types only (required fields are checked by validate_config, not here)
schema = self.service_schemas[service]
for field, expected_type in schema['types'].items():
if field in config and not isinstance(config[field], expected_type):
logger.error(f"Invalid type for {field}: expected {expected_type.__name__}")
return False
# Backup current config
self._backup_service_config(service)
# Update configuration
self.configs[service] = config
self._save_all_configs()
logger.info(f"Updated configuration for {service}")
return True
except Exception as e:
logger.error(f"Error updating config for {service}: {e}")
return False
def validate_config(self, service: str, config: Dict[str, Any]) -> Dict[str, Any]:
"""Validate configuration for a service"""
if service not in self.service_schemas:
return {
"valid": False,
"errors": [f"Unknown service: {service}"],
"warnings": []
}
schema = self.service_schemas[service]
errors = []
warnings = []
# Check required fields (missing = error, wrong type = error)
for field in schema['required']:
if field not in config:
errors.append(f"Missing required field: {field}")
elif field in schema['types']:
expected_type = schema['types'][field]
if not isinstance(config[field], expected_type):
errors.append(f"Field {field} must be of type {expected_type.__name__}")
# Check optional fields
for field in schema['optional']:
if field in config and field in schema['types']:
expected_type = schema['types'][field]
if not isinstance(config[field], expected_type):
warnings.append(f"Field {field} should be of type {expected_type.__name__}")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings
}
@staticmethod
def _validate_vol_entry(service_id: str, vol: dict) -> bool:
"""Return True if a backup volume entry is safe to use; log and return False otherwise."""
container = vol.get('container', '')
path = vol.get('path', '')
name = vol.get('name', '')
if not _SAFE_CONTAINER_RE.match(container):
logger.warning('Backup: unsafe container name %r for %s — skipping', container, service_id)
return False
if not path.startswith('/') or '..' in path.split('/') or '\x00' in path:
logger.warning('Backup: unsafe volume path %r for %s — skipping', path, service_id)
return False
if not _SAFE_VOL_NAME_RE.match(name):
logger.warning('Backup: unsafe volume name %r for %s — skipping', name, service_id)
return False
return True
def _backup_service_volumes(self, backup_path: Path, service_registry) -> None:
"""Stream service data out of each container via 'docker exec tar'.
Archives are relative (created with -C <path> .) so they can be safely
restored with -C <path> without risk of path traversal outside the volume.
Writes to a .partial temp file then renames atomically on success.
"""
try:
plan = service_registry.get_backup_plan()
except Exception as e:
logger.warning('_backup_service_volumes: could not get backup plan: %s', e)
return
for entry in plan:
service_id = entry['service_id']
volumes = entry.get('volumes') or []
if not volumes:
continue
svc_dir = backup_path / 'service_data' / service_id
svc_dir.mkdir(parents=True, exist_ok=True)
for vol in volumes:
if not self._validate_vol_entry(service_id, vol):
continue
container = vol['container']
path = vol['path']
name = vol['name']
archive_path = svc_dir / f'{name}.tar.gz'
tmp_path = svc_dir / f'{name}.tar.gz.partial'
try:
with open(tmp_path, 'wb') as af:
result = subprocess.run(
# -C path; then '.' archives the whole dir with relative entries.
# '--' prevents path/container from being parsed as options.
['docker', 'exec', '--', container,
'tar', '-C', path, '-czf', '-', '.'],
stdout=af,
stderr=subprocess.PIPE,
timeout=300,
)
if result.returncode != 0:
logger.warning(
'Backup: docker exec tar failed for %s/%s: %s',
service_id, name, result.stderr.decode(errors='replace'),
)
tmp_path.unlink(missing_ok=True)
else:
os.replace(tmp_path, archive_path)
logger.info('Backup: archived %s/%s', service_id, name)
except subprocess.TimeoutExpired:
logger.warning('Backup: timed out streaming %s/%s', service_id, name)
tmp_path.unlink(missing_ok=True)
except Exception as e:
logger.warning('Backup: failed to archive %s/%s: %s', service_id, name, e)
tmp_path.unlink(missing_ok=True)
def _restore_service_volumes(self, backup_path: Path, service_registry) -> None:
"""Pipe archived service data back into containers via 'docker exec -i tar'.
Extracts with -C <path>, matching how archives were created (relative paths).
This bounds extraction to within the declared volume directory.
"""
svc_data_dir = backup_path / 'service_data'
if not svc_data_dir.is_dir():
return
for svc_dir in svc_data_dir.iterdir():
if not svc_dir.is_dir():
continue
service_id = svc_dir.name
svc = service_registry.get(service_id)
if not svc:
logger.warning('Restore: unknown service %s in backup, skipping', service_id)
continue
volumes = (svc.get('backup') or {}).get('volumes') or []
for vol in volumes:
if not self._validate_vol_entry(service_id, vol):
continue
container = vol['container']
path = vol['path']
name = vol['name']
archive_path = svc_dir / f'{name}.tar.gz'
if not archive_path.exists():
continue
try:
with open(archive_path, 'rb') as af:
result = subprocess.run(
['docker', 'exec', '-i', '--', container,
'tar', '-C', path, '-xzf', '-'],
stdin=af,
stderr=subprocess.PIPE,
timeout=300,
)
if result.returncode != 0:
logger.warning(
'Restore: docker exec tar failed for %s/%s: %s',
service_id, name, result.stderr.decode(errors='replace'),
)
else:
logger.info('Restore: restored %s/%s', service_id, name)
except subprocess.TimeoutExpired:
logger.warning('Restore: timed out restoring %s/%s', service_id, name)
except Exception as e:
logger.warning('Restore: failed to restore %s/%s: %s', service_id, name, e)
def backup_config(self, service_registry=None) -> str:
"""Create a backup of cell_config.json, secrets, Caddyfile, .env, Corefile, DNS zones,
and (when service_registry is provided) live service data volumes."""
try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_id = f"backup_{timestamp}"
backup_path = self.backup_dir / backup_id
backup_path.mkdir(parents=True, exist_ok=True)
# Primary config and secrets
if self.config_file.exists():
shutil.copy2(self.config_file, backup_path / 'cell_config.json')
if self.secrets_file.exists():
shutil.copy2(self.secrets_file, backup_path / 'secrets.yaml')
# Runtime-generated files that must match cell_config.json after restore
config_dir = Path(os.environ.get('CONFIG_DIR', '/app/config'))
data_dir = Path(os.environ.get('DATA_DIR', '/app/data'))
env_file = Path(os.environ.get('ENV_FILE', '/app/.env'))
extra = [
(Path(LIVE_CADDYFILE), 'Caddyfile'),
(config_dir / 'dns' / 'Corefile', 'Corefile'),
(env_file, '.env'),
]
for src, dest_name in extra:
if src.exists():
shutil.copy2(src, backup_path / dest_name)
# DNS zone files
dns_data = data_dir / 'dns'
if dns_data.is_dir():
zones_dir = backup_path / 'dns_zones'
zones_dir.mkdir(exist_ok=True)
for zone_file in dns_data.glob('*.zone'):
shutil.copy2(zone_file, zones_dir / zone_file.name)
# Service-specific user account files (authoritative source of truth —
# cell_config.json only carries a best-effort sync of these).
svc_user_files = [
(data_dir / 'email' / 'users.json', 'email_users.json'),
(data_dir / 'calendar' / 'users.json', 'calendar_users.json'),
(data_dir / 'calendar' / 'calendars.json', 'calendar_calendars.json'),
]
for src, dest_name in svc_user_files:
if src.exists():
try:
shutil.copy2(src, backup_path / dest_name)
except (PermissionError, OSError) as e:
logger.warning(f"Could not back up {src.name}: {e} (skipping)")
# Live service data volumes (streamed via docker exec)
if service_registry is not None:
self._backup_service_volumes(backup_path, service_registry)
services = ['identity'] + list(self.service_schemas.keys())
manifest = {
"backup_id": backup_id,
"timestamp": datetime.now().isoformat(),
"services": services,
"files": [f.name for f in backup_path.iterdir()],
"includes_service_data": service_registry is not None,
}
with open(backup_path / 'manifest.json', 'w') as f:
json.dump(manifest, f, indent=2)
logger.info(f"Created configuration backup: {backup_id}")
return backup_id
except Exception as e:
logger.error(f"Error creating backup: {e}")
raise
def restore_config(self, backup_id: str, services: list = None,
service_registry=None) -> bool:
"""Restore from backup. If services list given, only restore those service configs (selective)."""
try:
backup_path = self.backup_dir / backup_id
if not backup_path.exists():
raise ValueError(f"Backup {backup_id} not found")
manifest_file = backup_path / 'manifest.json'
if not manifest_file.exists():
raise ValueError(f"Backup manifest not found")
if services is not None:
# Selective restore: only update specified services in running config
backup_cfg_path = backup_path / 'cell_config.json'
if backup_cfg_path.exists():
with open(backup_cfg_path) as f:
backup_cfg = json.load(f)
for svc in services:
if svc == 'identity':
if '_identity' in backup_cfg:
self.configs['_identity'] = backup_cfg['_identity']
elif svc in backup_cfg:
self.configs[svc] = backup_cfg[svc]
self._save_all_configs()
logger.info(f"Selectively restored {services} from backup: {backup_id}")
return True
# Full restore: copy all files back
config_backup = backup_path / 'cell_config.json'
if config_backup.exists():
shutil.copy2(config_backup, self.config_file)
secrets_backup = backup_path / 'secrets.yaml'
if secrets_backup.exists():
shutil.copy2(secrets_backup, self.secrets_file)
config_dir = Path(os.environ.get('CONFIG_DIR', '/app/config'))
data_dir = Path(os.environ.get('DATA_DIR', '/app/data'))
env_file = Path(os.environ.get('ENV_FILE', '/app/.env'))
restore_map = [
(backup_path / 'Caddyfile', Path(LIVE_CADDYFILE)),
(backup_path / 'Corefile', config_dir / 'dns' / 'Corefile'),
(backup_path / '.env', env_file),
]
for src, dest in restore_map:
if src.exists():
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
except (PermissionError, OSError) as copy_err:
logger.warning(f"Could not restore {dest}: {copy_err} (skipping)")
zones_backup = backup_path / 'dns_zones'
if zones_backup.is_dir():
dns_data = data_dir / 'dns'
try:
dns_data.mkdir(parents=True, exist_ok=True)
for zone_file in zones_backup.glob('*.zone'):
try:
shutil.copy2(zone_file, dns_data / zone_file.name)
except (PermissionError, OSError) as zone_err:
logger.warning(f"Could not restore zone {zone_file.name}: {zone_err} (skipping)")
except (PermissionError, OSError) as dir_err:
logger.warning(f"Could not create dns data dir {dns_data}: {dir_err} (skipping)")
# Service-specific user account files
svc_restore_map = [
(backup_path / 'email_users.json', data_dir / 'email' / 'users.json'),
(backup_path / 'calendar_users.json', data_dir / 'calendar' / 'users.json'),
(backup_path / 'calendar_calendars.json', data_dir / 'calendar' / 'calendars.json'),
]
for src, dest in svc_restore_map:
if src.exists():
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
except (PermissionError, OSError) as e:
logger.warning(f"Could not restore {dest.name}: {e} (skipping)")
# Live service data volumes
if service_registry is not None:
self._restore_service_volumes(backup_path, service_registry)
self.configs = self._load_all_configs()
logger.info(f"Restored configuration from backup: {backup_id}")
return True
except Exception as e:
logger.error(f"Error restoring backup {backup_id}: {e}")
return False
def list_backups(self) -> List[Dict[str, Any]]:
"""List all available backups"""
backups = []
for backup_dir in self.backup_dir.iterdir():
if backup_dir.is_dir():
manifest_file = backup_dir / 'manifest.json'
if manifest_file.exists():
try:
with open(manifest_file, 'r') as f:
manifest = json.load(f)
backups.append(manifest)
except Exception as e:
logger.error(f"Error reading backup manifest {backup_dir.name}: {e}")
return sorted(backups, key=lambda x: x['timestamp'], reverse=True)
def delete_backup(self, backup_id: str) -> bool:
"""Delete a backup"""
try:
backup_path = self.backup_dir / backup_id
if not backup_path.exists():
raise ValueError(f"Backup {backup_id} not found")
shutil.rmtree(backup_path)
logger.info(f"Deleted backup: {backup_id}")
return True
except Exception as e:
logger.error(f"Error deleting backup {backup_id}: {e}")
return False
def get_config_hash(self, service: str) -> str:
"""Get hash of service configuration for change detection"""
config = self.get_service_config(service)
config_str = json.dumps(config, sort_keys=True)
return hashlib.sha256(config_str.encode()).hexdigest()
def has_config_changed(self, service: str, previous_hash: str) -> bool:
"""Check if configuration has changed"""
current_hash = self.get_config_hash(service)
return current_hash != previous_hash
def export_config(self, format: str = 'json', services: list = None) -> str:
"""Export service configurations (excludes internal state like pending_restart)."""
try:
export_data = {}
# Include identity under a clean key
if '_identity' in self.configs:
export_data['identity'] = dict(self.configs['_identity'])
# Include service configs, skip internal _ keys
for key, val in self.configs.items():
if key.startswith('_'):
continue
if services is not None and key not in services:
continue
export_data[key] = val
if format == 'json':
return json.dumps(export_data, indent=2)
elif format == 'yaml':
return yaml.dump(export_data, default_flow_style=False)
else:
raise ValueError(f"Unsupported format: {format}")
except Exception as e:
logger.error(f"Error exporting config: {e}")
raise
def import_config(self, config_data: str, format: str = 'json', services: list = None) -> bool:
"""Import configurations from string. Merges into existing config."""
try:
if format == 'json':
configs = json.loads(config_data)
elif format == 'yaml':
configs = yaml.safe_load(config_data)
else:
raise ValueError(f"Unsupported format: {format}")
# Handle identity (exported as 'identity', stored as '_identity')
if 'identity' in configs and (services is None or 'identity' in services):
ident = configs['identity']
cur = dict(self.configs.get('_identity', {}))
for k in ('cell_name', 'domain', 'ip_range', 'wireguard_port'):
if k in ident:
cur[k] = ident[k]
self.configs['_identity'] = cur
# Merge service configs (don't replace wholesale — keep existing fields not in import)
for key, val in configs.items():
if key == 'identity':
continue
if key not in self.service_schemas:
continue
if services is not None and key not in services:
continue
cur_svc = dict(self.configs.get(key, {}))
cur_svc.update(val)
self.configs[key] = cur_svc
self._save_all_configs()
logger.info("Imported configurations successfully")
return True
except Exception as e:
logger.error(f"Error importing config: {e}")
return False
def _backup_service_config(self, service: str):
"""Create backup of specific service config before update"""
# No-op for unified config, but keep for compatibility
pass
def get_identity(self) -> Dict[str, Any]:
"""Return the current identity configuration."""
return self.configs.get('_identity', {})
def get_effective_domain(self) -> str:
"""Return the FQDN that public-facing services should use.
In lan mode: _identity.domain. Otherwise: _identity.domain_name
(falls back to domain if domain_name not yet registered)."""
ident = self.get_identity()
mode = ident.get('domain_mode', 'lan')
if mode == 'lan':
return ident.get('domain') or os.environ.get('CELL_DOMAIN', 'cell')
return (ident.get('domain_name')
or ident.get('domain')
or os.environ.get('CELL_DOMAIN', 'cell'))
def get_internal_domain(self) -> str:
"""Return the CoreDNS zone name (always _identity.domain)."""
ident = self.get_identity()
return ident.get('domain') or os.environ.get('CELL_DOMAIN', 'cell')
def set_identity_field(self, key: str, value: Any):
"""Set a single field in the identity configuration and persist."""
if '_identity' not in self.configs:
self.configs['_identity'] = {}
self.configs['_identity'][key] = value
self._save_all_configs()
def get_installed_services(self) -> dict:
return self.configs.get('_identity', {}).get('installed_services', {})
def set_installed_service(self, service_id: str, record: dict):
ident = self.configs.setdefault('_identity', {})
ident.setdefault('installed_services', {})[service_id] = record
self._save_all_configs()
def remove_installed_service(self, service_id: str):
ident = self.configs.setdefault('_identity', {})
ident.setdefault('installed_services', {}).pop(service_id, None)
ident.setdefault('service_ips', {}).pop(service_id, None)
self._save_all_configs()
# Phase 5 — Extended connectivity configuration helpers
def get_connectivity_config(self) -> Dict[str, Any]:
"""Return the full connectivity config (exits + peer_exit_map)."""
cfg = self.configs.get('connectivity')
if not isinstance(cfg, dict):
cfg = {'exits': {}, 'peer_exit_map': {}}
self.configs['connectivity'] = cfg
cfg.setdefault('exits', {})
cfg.setdefault('peer_exit_map', {})
return dict(cfg)
def set_ddns_config(self, ddns_cfg: Dict[str, Any]) -> None:
"""Replace the top-level ddns section and persist.
Never writes a 'token' key into cell_config.json — tokens live in data/.
"""
ddns_cfg = {k: v for k, v in ddns_cfg.items() if k != 'token'}
self.configs['ddns'] = ddns_cfg
self._save_all_configs()
@property
def _ddns_token_path(self) -> Path:
return self.data_dir / 'api' / 'ddns_token'
def get_ddns_token(self) -> str:
"""Return the DDNS bearer token from data/api/ddns_token.
Migrates automatically from the old cell_config.json location on first
call so existing installs keep working without manual intervention.
"""
path = self._ddns_token_path
if path.exists():
try:
tok = path.read_text().strip()
if tok:
return tok
except (PermissionError, OSError):
pass
# Migrate legacy token from cell_config.json
old_token = self.configs.get('ddns', {}).get('token', '')
if old_token:
self.set_ddns_token(old_token)
return old_token
def set_ddns_token(self, token: str) -> None:
"""Write the DDNS bearer token to data/api/ddns_token (not cell_config.json)."""
path = self._ddns_token_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(token)
except (PermissionError, OSError) as exc:
logger.error('set_ddns_token: failed to write token file: %s', exc)
return
# Remove from cell_config.json if a legacy copy is there
if self.configs.get('ddns', {}).get('token'):
ddns_cfg = {k: v for k, v in self.configs.get('ddns', {}).items() if k != 'token'}
self.configs['ddns'] = ddns_cfg
self._save_all_configs()
def set_connectivity_field(self, field: str, value: Any) -> bool:
"""Set a single field within the connectivity config and persist."""
cfg = self.configs.setdefault('connectivity', {'exits': {}, 'peer_exit_map': {}})
cfg[field] = value
try:
self._save_all_configs()
return True
except Exception as e:
logger.error(f"set_connectivity_field({field}): {e}")
return False
def get_all_configs(self) -> Dict[str, Dict]:
"""Get all service configurations"""
return self.configs.copy()
def get_config_summary(self) -> Dict[str, Any]:
"""Get summary of all configurations"""
summary = {
"total_services": len(self.service_schemas),
"configured_services": [],
"unconfigured_services": [],
"backup_count": len(self.list_backups()),
"last_backup": None
}
backups = self.list_backups()
if backups:
summary["last_backup"] = backups[0]["timestamp"]
for service in self.service_schemas.keys():
config = self.get_service_config(service)
if config and not config.get("error"):
summary["configured_services"].append(service)
else:
summary["unconfigured_services"].append(service)
return summary