From c1bdbf6a1258e97ddfbb62e046ed689d09a3e949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Mon, 25 May 2026 16:11:55 -0400 Subject: [PATCH] feat: hot-reload adn-server.yaml on SIGHUP Reload GLOBAL, REPORTS, ALIASES and SYSTEMS without full restart: add/remove UDP listeners (OBP, GENERATOR expansion), update bind addresses, preserve PEERS/STATS and active streams on unchanged ports. --- docs/en/server/user-guide/configuration.md | 20 +- docs/es/server/user-guide/configuration.md | 20 +- .../infrastructure/config_reload.py | 215 ++++++++++++++++++ .../twisted_adapters/udp_hbp.py | 8 + src/adn_server/main.py | 86 +++++-- 5 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 src/adn_server/infrastructure/config_reload.py diff --git a/docs/en/server/user-guide/configuration.md b/docs/en/server/user-guide/configuration.md index 0b14bb2..5726b47 100644 --- a/docs/en/server/user-guide/configuration.md +++ b/docs/en/server/user-guide/configuration.md @@ -5,7 +5,7 @@ | File | Committed | Role | |------|-----------|------| | `adn-server.example.yaml` | Yes | Template — copy to `adn-server.yaml` and edit. | -| `adn-server.yaml` | **No** (gitignored) | Main server: systems, globals, logging, aliases, reports. | +| `adn-server.yaml` | **No** (gitignored) | Main server: systems, globals, logging, aliases, reports. **Hot-reload** via `SIGHUP` (see [Configuration](configuration.md#hot-reload-adn-serveryaml)). | | `adn-voice.example.yaml` | Yes | Template for voice — copy to `adn-voice.yaml`. | | `adn-voice.yaml` | **No** (typical) | Voice/TTS/recording; merged into `config["VOICE"]` at startup and **hot-reloaded** (~every 15 s) if the file changes. | @@ -19,6 +19,24 @@ Optional: `--logging LEVEL` overrides `LOGGER.LOG_LEVEL`. If `adn-voice.yaml` sits next to `adn-server.yaml`, it is loaded automatically. You can also put a `VOICE:` block inside `adn-server.yaml`; the separate file is the usual way to change announcements without touching the main config. +### Hot reload (`adn-server.yaml`) + +After editing the main config you can reload **without restarting** the process (active voice streams on unchanged UDP listeners are preserved): + +```bash +kill -HUP $(pidof adn-server.py) # or: systemctl reload adn-server +``` + +With **systemd**, add to your unit file: + +```ini +ExecReload=/bin/kill -HUP $MAINPID +``` + +**Reload applies:** `GLOBAL`, `REPORTS`, `ALIASES`, per-system settings, **new/removed SYSTEMS** (including `GENERATOR` expansion and new OpenBridge legs), and updated bind addresses (listener restart for that system only). + +**Not reloaded:** `adn-voice.yaml` (separate 15 s loop), Python code, subscriber alias files (separate periodic reload). **BRIDGES** table is not rebuilt on reload — restart if bridge rules changed in a way that requires a full reset. + **Secrets:** Never commit real passphrases, security URLs, or `user_passwords.json` / `encryption_key.secret`. Use placeholders in templates and keep production files local. --- diff --git a/docs/es/server/user-guide/configuration.md b/docs/es/server/user-guide/configuration.md index cbc65e7..7e6b8f2 100644 --- a/docs/es/server/user-guide/configuration.md +++ b/docs/es/server/user-guide/configuration.md @@ -5,7 +5,7 @@ | Fichero | En el repo | Rol | |---------|-------------|-----| | `adn-server.example.yaml` | Sí | Plantilla — copiar a `adn-server.yaml` y editar. | -| `adn-server.yaml` | **No** (gitignored) | Servidor principal: sistemas, globales, logging, alias, informes. | +| `adn-server.yaml` | **No** (gitignored) | Servidor principal: sistemas, globales, logging, alias, informes. **Recarga en caliente** con `SIGHUP` (ver [Configuración](configuration.md#recarga-en-caliente-adn-serveryaml)). | | `adn-voice.example.yaml` | Sí | Plantilla de voz — copiar a `adn-voice.yaml`. | | `adn-voice.yaml` | **No** (habitual) | Voz/TTS/grabación; se fusiona en `config["VOICE"]` al arranque y **recarga en caliente** (~cada 15 s) si cambia el fichero. | @@ -19,6 +19,24 @@ Opcional: `--logging LEVEL` sobrescribe `LOGGER.LOG_LEVEL`. Si `adn-voice.yaml` está junto a `adn-server.yaml`, se carga automáticamente. También puedes poner un bloque `VOICE:` dentro de `adn-server.yaml`; el fichero separado es la forma habitual de cambiar anuncios sin tocar la config principal. +### Recarga en caliente (`adn-server.yaml`) + +Tras editar la config principal puedes recargar **sin reiniciar** el proceso (se conservan streams de voz activos en listeners UDP que no cambian): + +```bash +kill -HUP $(pidof adn-server.py) # o: systemctl reload adn-server +``` + +Con **systemd**, en la unidad: + +```ini +ExecReload=/bin/kill -HUP $MAINPID +``` + +**Se recarga:** `GLOBAL`, `REPORTS`, `ALIASES`, parámetros por system, **systems nuevos/eliminados** (incluida expansión `GENERATOR` y OBP nuevos), y cambios de IP/puerto (solo reinicia el listener de ese system). + +**No se recarga:** `adn-voice.yaml` (loop aparte cada 15 s), código Python, ficheros de alias (recarga periódica). La tabla **BRIDGES** no se reconstruye — reinicia si cambiaste reglas de bridge que exijan reset completo. + **Secretos:** no versionar passphrases reales, URLs de seguridad ni `user_passwords.json` / `encryption_key.secret`. Usa placeholders en plantillas y mantén producción en local. --- diff --git a/src/adn_server/infrastructure/config_reload.py b/src/adn_server/infrastructure/config_reload.py new file mode 100644 index 0000000..1cebb1a --- /dev/null +++ b/src/adn_server/infrastructure/config_reload.py @@ -0,0 +1,215 @@ +# ADN DMR Peer Server - hot reload adn-server.yaml +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY + +"""Reload SYSTEMS / GLOBAL from adn-server.yaml without full process restart.""" + +from __future__ import annotations + +import copy +import logging +from dataclasses import dataclass +from typing import Any, Callable + +from ..domain.errors import ConfigError +from .config_loader import YamlConfigLoader +from .config_normalizer import ( + apply_talker_alias_defaults, + ensure_system_runtime_config, + expand_generator, + normalize_obp_config, + normalize_peer_config, +) + +logger = logging.getLogger(__name__) + +_RUNTIME_TOP_KEYS = frozenset({ + "_SUB_MAP", + "_SUB_IDS", + "_SUB_PROFILES", + "_PEER_IDS", + "_TG_IDS", + "_LOCAL_SUBSCRIBER_IDS", + "_SERVER_IDS", + "CHECKSUMS", +}) + + +@dataclass(frozen=True) +class BindSpec: + ip: str + port: int + + +def bind_spec(sys_cfg: dict[str, Any]) -> BindSpec: + ip = str(sys_cfg.get("IP") or "0.0.0.0") + return BindSpec(ip=ip, port=int(sys_cfg.get("PORT", 56400))) + + +def enabled_systems(systems: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + name: cfg + for name, cfg in systems.items() + if cfg.get("ENABLED", True) + } + + +def prepare_incoming_config( + loader: YamlConfigLoader, + config_path: str, + log: logging.Logger, +) -> dict[str, Any]: + """Load YAML and apply the same normalizers as startup (including GENERATOR expand).""" + incoming = loader.load(config_path) + apply_talker_alias_defaults(incoming) + expand_generator(incoming, log) + ensure_system_runtime_config(incoming) + normalize_peer_config(incoming) + normalize_obp_config(incoming) + return incoming + + +def merge_system_config(old_cfg: dict[str, Any], new_cfg: dict[str, Any]) -> dict[str, Any]: + """Apply new system settings while keeping live runtime state (PEERS, STATS).""" + merged = copy.deepcopy(new_cfg) + mode = merged.get("MODE") + if mode == "MASTER": + merged["PEERS"] = old_cfg.get("PEERS", {}) + elif mode == "PEER": + stats = old_cfg.get("STATS") + if isinstance(stats, dict): + merged["STATS"] = stats + return merged + + +def merge_top_level_config(config: dict[str, Any], incoming: dict[str, Any]) -> None: + """Update GLOBAL / REPORTS / ALIASES / LOGGER in the live config dict.""" + kill_flag = config.get("GLOBAL", {}).get("_KILL_SERVER") + for key in ("GLOBAL", "REPORTS", "ALIASES", "LOGGER"): + if key not in incoming: + continue + config[key] = copy.deepcopy(incoming[key]) + if kill_flag is not None: + config.setdefault("GLOBAL", {})["_KILL_SERVER"] = kill_flag + for rk in _RUNTIME_TOP_KEYS: + if rk in config: + continue + if rk in incoming: + config[rk] = incoming[rk] + + +@dataclass +class ReloadResult: + added: list[str] + removed: list[str] + updated: list[str] + rebound: list[str] + + +def reload_server_config( + config: dict[str, Any], + config_path: str, + loader: YamlConfigLoader, + protocols: dict[str, Any], + transports: dict[str, Any], + *, + create_protocol: Callable[[str], Any], + listen_udp: Callable[[str, BindSpec, Any], Any], + stop_listener: Callable[[Any], None], + on_systems_changed: Callable[[], None] | None = None, + log: logging.Logger | None = None, +) -> ReloadResult: + """ + Re-read adn-server.yaml, diff SYSTEMS, start/stop UDP listeners. + + Preserves PEERS / STATS and protocol STATUS for systems that stay up with the + same bind address. Runs on the Twisted reactor thread. + """ + log = log or logger + try: + incoming = prepare_incoming_config(loader, config_path, log) + except ConfigError as e: + log.error("(CONFIG-RELOAD) failed to load config: %s", e) + raise + except Exception as e: + log.error("(CONFIG-RELOAD) failed to prepare config: %s", e) + raise + + merge_top_level_config(config, incoming) + + old_systems = dict(config.get("SYSTEMS", {})) + new_systems = incoming.get("SYSTEMS", {}) + old_enabled = set(enabled_systems(old_systems)) + new_enabled = set(enabled_systems(new_systems)) + + added: list[str] = [] + removed: list[str] = [] + updated: list[str] = [] + rebound: list[str] = [] + + for name in sorted(old_enabled - new_enabled): + port = transports.pop(name, None) + proto = protocols.pop(name, None) + if proto is not None: + try: + proto.dereg() + except Exception as e: + log.warning("(CONFIG-RELOAD) dereg %s: %s", name, e) + if port is not None: + stop_listener(port) + config.get("SYSTEMS", {}).pop(name, None) + removed.append(name) + log.info("(CONFIG-RELOAD) removed system %s", name) + + for name in sorted(new_enabled - old_enabled): + sys_cfg = copy.deepcopy(new_systems[name]) + config.setdefault("SYSTEMS", {})[name] = sys_cfg + proto = create_protocol(name) + bind = bind_spec(sys_cfg) + transports[name] = listen_udp(name, bind, proto) + protocols[name] = proto + added.append(name) + log.info("(CONFIG-RELOAD) added system %s on %s:%s", name, bind.ip, bind.port) + + for name in sorted(old_enabled & new_enabled): + old_cfg = old_systems[name] + new_cfg = new_systems[name] + old_bind = bind_spec(old_cfg) + new_bind = bind_spec(new_cfg) + merged = merge_system_config(old_cfg, new_cfg) + config["SYSTEMS"][name] = merged + proto = protocols.get(name) + if proto is None: + proto = create_protocol(name) + transports[name] = listen_udp(name, new_bind, proto) + protocols[name] = proto + added.append(name) + log.info("(CONFIG-RELOAD) started missing listener %s on %s:%s", name, new_bind.ip, new_bind.port) + continue + if hasattr(proto, "apply_system_config"): + proto.apply_system_config(config) + if old_bind != new_bind: + port = transports.get(name) + if port is not None: + stop_listener(port) + transports[name] = listen_udp(name, new_bind, proto) + rebound.append(name) + log.info( + "(CONFIG-RELOAD) rebound %s %s:%s -> %s:%s", + name, old_bind.ip, old_bind.port, new_bind.ip, new_bind.port, + ) + else: + updated.append(name) + log.debug("(CONFIG-RELOAD) updated system %s (bind unchanged)", name) + + for name in sorted(set(old_systems) - old_enabled): + if name not in new_systems: + config.get("SYSTEMS", {}).pop(name, None) + + if on_systems_changed and (added or removed or updated or rebound): + on_systems_changed() + + log.info( + "(CONFIG-RELOAD) complete: +%s -%s updated=%s rebound=%s", + len(added), len(removed), len(updated), len(rebound), + ) + return ReloadResult(added=added, removed=removed, updated=updated, rebound=rebound) diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index eedccc4..3d93fe4 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -219,6 +219,14 @@ class HBPProtocol(DatagramProtocol): _maint_d = self._maintenance_loop.start(ping_time) _maint_d.addErrback(self._looping_err_handle) + def apply_system_config(self, config: dict[str, Any]) -> None: + """Hot-reload: refresh system dict from live CONFIG (keeps STATUS / streams).""" + self._CONFIG = config + sys_cfg = config.get("SYSTEMS", {}).get(self._system, {}) + self._config = sys_cfg + if sys_cfg.get("MODE") == "MASTER": + self._peers = sys_cfg.get("PEERS", {}) + # ── Exact port of hblink.py send_peers / send_peer / send_master / send_system ── def send_peers(self, _packet: bytes, _hops: bytes = b"", _ber: bytes = b"\x00", _rssi: bytes = b"\x00", _source_server: bytes = b"\x00\x00\x00\x00", _source_rptr: bytes = b"\x00\x00\x00\x00") -> None: diff --git a/src/adn_server/main.py b/src/adn_server/main.py index b738a3c..c9fbbb0 100644 --- a/src/adn_server/main.py +++ b/src/adn_server/main.py @@ -49,6 +49,7 @@ from twisted.internet import reactor, task, threads from .domain import bytes_3 from .infrastructure import YamlConfigLoader, reopen_file_handlers, setup_logging +from .infrastructure.config_reload import BindSpec, reload_server_config from .infrastructure.config_normalizer import ( apply_talker_alias_defaults as _apply_talker_alias_defaults, expand_generator as _expand_generator, @@ -213,6 +214,7 @@ def main() -> None: # Protocol registry for send_to_system (legacy: systems[name].send_system(packet)) protocols: dict[str, Any] = {} + udp_ports: dict[str, Any] = {} report_factory = ReportServerFactory(config) def send_to_system(system_name: str, packet: bytes, **kwargs: Any) -> None: @@ -443,9 +445,65 @@ def main() -> None: n = reopen_file_handlers() logger.info("(LOGGER) Reopened %s file log handler(s) after SIGUSR2", n) + def _create_hbp_protocol(system_name: str) -> Any: + return HBPProtocolFactory( + system_name, + config, + report_factory, + router=bridge_router, + dmrd_received=bridge_use_cases.dmrd_received, + get_user_password_callback=user_passwords_loader.get_user_password, + on_play_file_request=voice_use_cases.play_file_on_request, + on_handle_recording=recording_handler.handle_recording, + on_in_band_signalling=bridge_use_cases.apply_in_band_signalling, + on_options_received=bridge_use_cases.options_config_for_system, + on_deactivate_dynamic_bridges=bridge_use_cases.deactivate_all_dynamic_bridges, + on_obp_bcsq_received=bridge_use_cases.on_obp_bcsq_received, + on_talker_alias_local_repeat=bridge_use_cases.send_talker_alias_local_repeat, + on_talker_alias_stream_end=bridge_use_cases.clear_talker_alias_stream, + ) + + def _listen_system(_name: str, bind: BindSpec, protocol: Any) -> Any: + port = reactor.listenUDP(bind.port, protocol, interface=bind.ip or "0.0.0.0") + logger.info("(GLOBAL) UDP %s listening on %s:%s", _name, bind.ip or "*", bind.port) + return port + + def _stop_udp_port(port: Any) -> None: + if port is not None: + port.stopListening() + + def _on_config_systems_changed() -> None: + report_factory.set_systems(config.get("SYSTEMS", {})) + user_passwords_loader.load(config) + + def _do_config_reload() -> None: + try: + reload_server_config( + config, + config_path, + loader, + protocols, + udp_ports, + create_protocol=_create_hbp_protocol, + listen_udp=lambda n, b, p: _listen_system(n, b, p), + stop_listener=_stop_udp_port, + on_systems_changed=_on_config_systems_changed, + log=logger, + ) + except Exception as e: + logger.error("(CONFIG-RELOAD) aborted: %s", e) + + def sighup_reload_config(_sig, _frame): + """Reload adn-server.yaml (SYSTEMS, GLOBAL); keeps active streams on unchanged listeners.""" + logger.info("(CONFIG-RELOAD) SIGHUP received, scheduling reload") + reactor.callLater(0, _do_config_reload) + signal.signal(signal.SIGTERM, sig_handler) signal.signal(signal.SIGINT, sig_handler) signal.signal(signal.SIGUSR2, sigusr2_reopen_logs) + if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, sighup_reload_config) + logger.info("(CONFIG-RELOAD) SIGHUP handler active (systemctl reload / kill -HUP)") reactor.addSystemEventTrigger("before", "shutdown", shutdown_handler) # Voice config reload (15s): re-read adn-voice.yaml, start/stop announcement LoopingCalls on change @@ -480,28 +538,16 @@ def main() -> None: for system_name, sys_cfg in systems_cfg.items(): if not sys_cfg.get("ENABLED", True): continue - ip = sys_cfg.get("IP", "") - udp_port = sys_cfg.get("PORT", 56400) - protocol = HBPProtocolFactory( + bind = BindSpec(ip=str(sys_cfg.get("IP") or "0.0.0.0"), port=int(sys_cfg.get("PORT", 56400))) + protocol = _create_hbp_protocol(system_name) + udp_ports[system_name] = _listen_system(system_name, bind, protocol) + protocols[system_name] = protocol + logger.debug( + "(GLOBAL) %s instance created: %s, %s", + sys_cfg.get("MODE", "?"), system_name, - config, - report_factory, - router=bridge_router, - dmrd_received=bridge_use_cases.dmrd_received, - get_user_password_callback=user_passwords_loader.get_user_password, - on_play_file_request=voice_use_cases.play_file_on_request, - on_handle_recording=recording_handler.handle_recording, - on_in_band_signalling=bridge_use_cases.apply_in_band_signalling, - on_options_received=bridge_use_cases.options_config_for_system, - on_deactivate_dynamic_bridges=bridge_use_cases.deactivate_all_dynamic_bridges, - on_obp_bcsq_received=bridge_use_cases.on_obp_bcsq_received, - on_talker_alias_local_repeat=bridge_use_cases.send_talker_alias_local_repeat, - on_talker_alias_stream_end=bridge_use_cases.clear_talker_alias_stream, + protocol, ) - protocols[system_name] = protocol - reactor.listenUDP(udp_port, protocol, interface=ip or "0.0.0.0") - logger.debug("(GLOBAL) %s instance created: %s, %s", sys_cfg.get("MODE", "?"), system_name, protocol) - logger.info("(GLOBAL) UDP %s listening on %s:%s", system_name, ip or "*", udp_port) logger.info("(GLOBAL) ADN DMR Peer Server started. Use adn-dmr-server as reference.") reactor.suggestThreadPoolSize(100)