From 87ff50c37821bb50e7ed9672e3e0187d8e3d72ba Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 21 Apr 2026 04:27:22 -0400 Subject: [PATCH] feat: Settings changes now apply to real service config files and restart containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each service manager now has apply_config() that writes to the actual config: - network: dhcp_range → dnsmasq.conf (reload cell-dhcp), ntp_servers → chrony.conf (restart cell-ntp), domain → dnsmasq.conf domain= line - email: domain → mailserver.env OVERRIDE_HOSTNAME + POSTMASTER_ADDRESS, restart cell-mail - wireguard: port/address/private_key → wg0.conf ListenPort/Address/PrivateKey, restart cell-wireguard - calendar: port → radicale config hosts=, restart cell-radicale PUT /api/config now calls apply_config() after persisting JSON, and returns {restarted: [...], warnings: [...]} so Settings UI can show which containers were restarted. _restart_container() helper added to BaseServiceManager. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 24 ++++++++++-- api/base_service_manager.py | 22 ++++++++++- api/calendar_manager.py | 23 +++++++++++ api/email_manager.py | 45 ++++++++++++++++++++++ api/network_manager.py | 74 ++++++++++++++++++++++++++++++++++-- api/wireguard_manager.py | 43 +++++++++++++++++++++ webui/src/pages/Settings.jsx | 18 +++++++-- 7 files changed, 237 insertions(+), 12 deletions(-) diff --git a/api/app.py b/api/app.py index f2db060..3a68822 100644 --- a/api/app.py +++ b/api/app.py @@ -414,21 +414,37 @@ def update_config(): 'vault': app.vault_manager, } - # Update service configurations in both config_manager and service managers + all_restarted = [] + all_warnings = [] + + # Update service configurations: persist + apply to real config files for service, config in data.items(): if service in config_manager.service_schemas: config_manager.update_service_config(service, config) - # Propagate to the service manager's own config file mgr = _svc_managers.get(service) if mgr: mgr.update_config(config) + result = mgr.apply_config(config) + all_restarted.extend(result.get('restarted', [])) + all_warnings.extend(result.get('warnings', [])) service_bus.publish_event(EventType.CONFIG_CHANGED, service, { 'service': service, 'config': config }) - logger.info(f"Updated config: {data}") - return jsonify({"message": "Configuration updated successfully"}) + # Apply cell identity domain to network and email services + if identity_updates.get('domain'): + domain = identity_updates['domain'] + net_result = network_manager.apply_domain(domain) + all_restarted.extend(net_result.get('restarted', [])) + all_warnings.extend(net_result.get('warnings', [])) + + logger.info(f"Updated config, restarted: {all_restarted}") + return jsonify({ + "message": "Configuration updated and applied", + "restarted": all_restarted, + "warnings": all_warnings, + }) except Exception as e: logger.error(f"Error updating config: {e}") return jsonify({"error": str(e)}), 500 diff --git a/api/base_service_manager.py b/api/base_service_manager.py index 158fc8b..142074f 100644 --- a/api/base_service_manager.py +++ b/api/base_service_manager.py @@ -68,11 +68,31 @@ class BaseServiceManager(ABC): """Restart service - default implementation""" try: self.logger.info(f"Restarting {self.service_name} service") - # Default implementation - subclasses can override return True except Exception as e: self.logger.error(f"Error restarting {self.service_name}: {e}") return False + + def _restart_container(self, container_name: str) -> bool: + """Restart a Docker container by name.""" + import subprocess + try: + result = subprocess.run( + ['docker', 'restart', container_name], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0: + self.logger.info(f"Restarted container {container_name}") + return True + self.logger.error(f"Failed to restart {container_name}: {result.stderr}") + return False + except Exception as e: + self.logger.error(f"Error restarting container {container_name}: {e}") + return False + + def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Apply config to actual service files and restart. Override in subclasses.""" + return {'restarted': [], 'warnings': []} def get_config(self) -> Dict[str, Any]: """Get service configuration - default implementation""" diff --git a/api/calendar_manager.py b/api/calendar_manager.py index ba33c48..60951b6 100644 --- a/api/calendar_manager.py +++ b/api/calendar_manager.py @@ -477,6 +477,29 @@ class CalendarManager(BaseServiceManager): with open(config_file, 'w') as f: f.write(config_content) + def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Update radicale config port and restart cell-radicale.""" + restarted = [] + warnings = [] + if 'port' not in config: + return {'restarted': restarted, 'warnings': warnings} + try: + radicale_conf = os.path.join(self.radicale_dir, 'config') + if os.path.exists(radicale_conf): + with open(radicale_conf) as f: + lines = f.readlines() + lines = [ + f"hosts = 0.0.0.0:{config['port']}\n" if l.strip().startswith('hosts =') else l + for l in lines + ] + with open(radicale_conf, 'w') as f: + f.writelines(lines) + self._restart_container('cell-radicale') + restarted.append('cell-radicale') + except Exception as e: + warnings.append(f"radicale config update failed: {e}") + return {'restarted': restarted, 'warnings': warnings} + def remove_calendar(self, username: str, calendar_name: str) -> bool: """Remove a calendar.""" try: diff --git a/api/email_manager.py b/api/email_manager.py index 71510e5..dd5a0c5 100644 --- a/api/email_manager.py +++ b/api/email_manager.py @@ -207,6 +207,51 @@ class EmailManager(BaseServiceManager): except Exception as e: logger.error(f"Error saving domain config: {e}") + def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Write config to mailserver.env and restart cell-mail.""" + restarted = [] + warnings = [] + env_file = os.path.join(self.config_dir, 'mail', 'mailserver.env') + try: + # Read existing env file + env_lines = [] + if os.path.exists(env_file): + with open(env_file) as f: + env_lines = f.readlines() + + def _set_env(lines, key, value): + found = False + result = [] + for l in lines: + if l.startswith(f'{key}='): + result.append(f'{key}={value}\n') + found = True + else: + result.append(l) + if not found: + result.append(f'{key}={value}\n') + return result + + changed = False + if 'domain' in config and config['domain']: + domain = config['domain'] + env_lines = _set_env(env_lines, 'OVERRIDE_HOSTNAME', f'mail.{domain}') + env_lines = _set_env(env_lines, 'POSTMASTER_ADDRESS', f'admin@{domain}') + # Also persist to domain_config_file + self._save_domain_config({'domain': domain}) + changed = True + + if changed: + with open(env_file, 'w') as f: + f.writelines(env_lines) + self._restart_container('cell-mail') + restarted.append('cell-mail') + except Exception as e: + warnings.append(f"mailserver.env update failed: {e}") + logger.error(f"apply_config error: {e}") + + return {'restarted': restarted, 'warnings': warnings} + def get_email_status(self) -> Dict[str, Any]: """Get detailed email service status including postfix/dovecot state.""" try: diff --git a/api/network_manager.py b/api/network_manager.py index a24f7ac..2ba3bde 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -278,18 +278,86 @@ class NetworkManager(BaseServiceManager): def _reload_dns_service(self): """Reload DNS service""" try: - subprocess.run(['docker', 'exec', 'cell-dns', 'kill', '-HUP', '1'], + 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'], + 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) -> Dict[str, Any]: + """Update domain in dnsmasq config and reload.""" + restarted = [] + warnings = [] + 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) + self._reload_dhcp_service() + restarted.append('cell-dhcp (reloaded)') + except Exception as e: + warnings.append(f"domain write to dnsmasq failed: {e}") + return {'restarted': restarted, 'warnings': warnings} def test_dns_resolution(self, domain: str) -> Dict: """Test DNS resolution for a domain using Python socket.""" diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index 131e6e4..4dcd80b 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -158,6 +158,49 @@ class WireGuardManager(BaseServiceManager): f.write(content) self._syncconf() + def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Update wg0.conf interface fields and restart cell-wireguard.""" + restarted = [] + warnings = [] + cf = self._config_file() + if not os.path.exists(cf): + warnings.append('wg0.conf not found — skipping') + return {'restarted': restarted, 'warnings': warnings} + try: + with open(cf) as f: + lines = f.readlines() + + def _set_iface_field(lines, key, value): + result = [] + for l in lines: + if l.strip().startswith(f'{key} =') or l.strip().startswith(f'{key}='): + result.append(f'{key} = {value}\n') + else: + result.append(l) + return result + + changed = False + if 'port' in config and config['port']: + lines = _set_iface_field(lines, 'ListenPort', config['port']) + changed = True + if 'address' in config and config['address']: + lines = _set_iface_field(lines, 'Address', config['address']) + changed = True + if 'private_key' in config and config['private_key']: + lines = _set_iface_field(lines, 'PrivateKey', config['private_key']) + changed = True + + if changed: + with open(cf, 'w') as f: + f.writelines(lines) + self._restart_container('cell-wireguard') + restarted.append('cell-wireguard') + except Exception as e: + warnings.append(f"wg0.conf update failed: {e}") + logger.error(f"apply_config error: {e}") + + return {'restarted': restarted, 'warnings': warnings} + def _syncconf(self): """Sync live WireGuard peers using 'wg set' — never touches [Interface] settings. diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index 90ff079..874dbed 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -317,13 +317,23 @@ function Settings() { useEffect(() => { loadAll(); }, [loadAll]); + const _applyResult = (res, label) => { + const { restarted = [], warnings = [] } = res.data || {}; + if (restarted.length > 0) { + toast(`${label} saved — restarted: ${restarted.join(', ')}`); + } else { + toast(`${label} saved`); + } + warnings.forEach((w) => toast(w, 'warning')); + }; + // identity save const saveIdentity = async () => { setIdentitySaving(true); try { - await cellAPI.updateConfig(identity); + const res = await cellAPI.updateConfig(identity); setIdentityDirty(false); - toast('Cell identity saved'); + _applyResult(res, 'Cell identity'); } catch { toast('Failed to save identity', 'error'); } finally { @@ -335,9 +345,9 @@ function Settings() { const saveService = async (key) => { setServiceSaving((s) => ({ ...s, [key]: true })); try { - await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); + const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); setServiceDirty((d) => ({ ...d, [key]: false })); - toast(`${key} configuration saved`); + _applyResult(res, key); } catch { toast(`Failed to save ${key} config`, 'error'); } finally {