feat: Settings changes now apply to real service config files and restart containers

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 04:27:22 -04:00
parent ae73246878
commit 87ff50c378
7 changed files with 237 additions and 12 deletions
+20 -4
View File
@@ -414,21 +414,37 @@ def update_config():
'vault': app.vault_manager, '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(): for service, config in data.items():
if service in config_manager.service_schemas: if service in config_manager.service_schemas:
config_manager.update_service_config(service, config) config_manager.update_service_config(service, config)
# Propagate to the service manager's own config file
mgr = _svc_managers.get(service) mgr = _svc_managers.get(service)
if mgr: if mgr:
mgr.update_config(config) 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_bus.publish_event(EventType.CONFIG_CHANGED, service, {
'service': service, 'service': service,
'config': config 'config': config
}) })
logger.info(f"Updated config: {data}") # Apply cell identity domain to network and email services
return jsonify({"message": "Configuration updated successfully"}) 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: except Exception as e:
logger.error(f"Error updating config: {e}") logger.error(f"Error updating config: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
+21 -1
View File
@@ -68,12 +68,32 @@ class BaseServiceManager(ABC):
"""Restart service - default implementation""" """Restart service - default implementation"""
try: try:
self.logger.info(f"Restarting {self.service_name} service") self.logger.info(f"Restarting {self.service_name} service")
# Default implementation - subclasses can override
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error restarting {self.service_name}: {e}") self.logger.error(f"Error restarting {self.service_name}: {e}")
return False 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]: def get_config(self) -> Dict[str, Any]:
"""Get service configuration - default implementation""" """Get service configuration - default implementation"""
try: try:
+23
View File
@@ -477,6 +477,29 @@ class CalendarManager(BaseServiceManager):
with open(config_file, 'w') as f: with open(config_file, 'w') as f:
f.write(config_content) 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: def remove_calendar(self, username: str, calendar_name: str) -> bool:
"""Remove a calendar.""" """Remove a calendar."""
try: try:
+45
View File
@@ -207,6 +207,51 @@ class EmailManager(BaseServiceManager):
except Exception as e: except Exception as e:
logger.error(f"Error saving domain config: {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]: def get_email_status(self) -> Dict[str, Any]:
"""Get detailed email service status including postfix/dovecot state.""" """Get detailed email service status including postfix/dovecot state."""
try: try:
+68
View File
@@ -291,6 +291,74 @@ class NetworkManager(BaseServiceManager):
except Exception as e: except Exception as e:
logger.error(f"Failed to reload DHCP service: {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: def test_dns_resolution(self, domain: str) -> Dict:
"""Test DNS resolution for a domain using Python socket.""" """Test DNS resolution for a domain using Python socket."""
import socket import socket
+43
View File
@@ -158,6 +158,49 @@ class WireGuardManager(BaseServiceManager):
f.write(content) f.write(content)
self._syncconf() 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): def _syncconf(self):
"""Sync live WireGuard peers using 'wg set' — never touches [Interface] settings. """Sync live WireGuard peers using 'wg set' — never touches [Interface] settings.
+14 -4
View File
@@ -317,13 +317,23 @@ function Settings() {
useEffect(() => { loadAll(); }, [loadAll]); 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 // identity save
const saveIdentity = async () => { const saveIdentity = async () => {
setIdentitySaving(true); setIdentitySaving(true);
try { try {
await cellAPI.updateConfig(identity); const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false); setIdentityDirty(false);
toast('Cell identity saved'); _applyResult(res, 'Cell identity');
} catch { } catch {
toast('Failed to save identity', 'error'); toast('Failed to save identity', 'error');
} finally { } finally {
@@ -335,9 +345,9 @@ function Settings() {
const saveService = async (key) => { const saveService = async (key) => {
setServiceSaving((s) => ({ ...s, [key]: true })); setServiceSaving((s) => ({ ...s, [key]: true }));
try { try {
await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
setServiceDirty((d) => ({ ...d, [key]: false })); setServiceDirty((d) => ({ ...d, [key]: false }));
toast(`${key} configuration saved`); _applyResult(res, key);
} catch { } catch {
toast(`Failed to save ${key} config`, 'error'); toast(`Failed to save ${key} config`, 'error');
} finally { } finally {