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:
+20
-4
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+71
-3
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user