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,
|
'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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user