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.
pull/4/head
Rodrigo Pérez 4 months ago
parent 24d835948e
commit c1bdbf6a12

@ -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.
---

@ -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.
---

@ -0,0 +1,215 @@
# ADN DMR Peer Server - hot reload adn-server.yaml
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
"""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)

@ -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:

@ -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)

Loading…
Cancel
Save

Powered by TurnKey Linux.