Enhance configuration management and companion node handling

- Added backup functionality for config.yaml during save operations.
- Improved YAML saving to support Unicode characters and increased line width.
- Introduced a mechanism to sync companion node names to the configuration upon updates.
- Updated API endpoints to validate companion node names and persist changes to the configuration.
- Enhanced the RepeaterCompanionBridge to load and save preferences, ensuring consistent state management.
This commit is contained in:
agessaman
2026-03-05 20:04:17 -08:00
parent 0217a49ed2
commit 5b0359b74b
7 changed files with 94 additions and 7 deletions
+1
View File
@@ -52,6 +52,7 @@ htmlcov/
# Config
config.yaml
config.yaml.backup
identity.json
# Data
+9
View File
@@ -53,9 +53,11 @@ class RepeaterCompanionBridge(CompanionBridge):
*,
sqlite_handler=None,
companion_hash: str = "",
on_prefs_saved: Optional[Callable[[str], None]] = None,
) -> None:
self._sqlite_handler = sqlite_handler
self._companion_hash = companion_hash
self._on_prefs_saved = on_prefs_saved
super().__init__(
identity=identity,
packet_injector=packet_injector,
@@ -68,6 +70,8 @@ class RepeaterCompanionBridge(CompanionBridge):
authenticate_callback=authenticate_callback,
initial_contacts=initial_contacts,
)
# Load persisted prefs (e.g. node_name) from SQLite so matching uses last-saved name
self._load_prefs()
def _save_prefs(self) -> None:
"""Persist full NodePrefs as JSON to SQLite."""
@@ -79,6 +83,11 @@ class RepeaterCompanionBridge(CompanionBridge):
self._sqlite_handler.companion_save_prefs(
str(self._companion_hash), prefs_safe
)
if self._on_prefs_saved:
try:
self._on_prefs_saved(self.prefs.node_name)
except Exception as e:
logger.warning("Failed to sync node_name to config: %s", e)
except Exception as e:
logger.warning("Failed to persist companion prefs: %s", e)
+17
View File
@@ -0,0 +1,17 @@
"""Shared utilities for Companion (e.g. validation for config sync)."""
_INVALID_NODE_NAME_CHARS = "\n\r\x00"
def validate_companion_node_name(value: str) -> str:
"""Validate node_name for config sync: non-empty, max 31 bytes UTF-8, no control chars."""
if not isinstance(value, str):
raise ValueError("node_name must be a string")
s = value.strip()
if not s:
raise ValueError("node_name cannot be empty")
if len(s.encode("utf-8")) > 31:
raise ValueError("node_name too long (max 31 bytes UTF-8)")
if any(c in s for c in _INVALID_NODE_NAME_CHARS):
raise ValueError("node_name contains invalid characters")
return s
+10 -3
View File
@@ -111,9 +111,16 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
config_file.rename(backup_path)
logger.info(f"Created backup at {backup_path}")
# Save new config
with open(config_path, "w") as f:
yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False)
# Save new config (allow_unicode=True so emojis etc. are not escaped as \U0001F47E)
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(
config_data,
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=1000000,
)
logger.info(f"Saved configuration to {config_path}")
return True
+22 -1
View File
@@ -4,7 +4,8 @@ import os
import sys
import time
from repeater.config import get_radio_for_board, load_config
from repeater.companion.utils import validate_companion_node_name
from repeater.config import get_radio_for_board, load_config, save_config
from repeater.config_manager import ConfigManager
from repeater.engine import RepeaterHandler
from repeater.handler_helpers import (
@@ -415,6 +416,25 @@ class RepeaterDaemon:
tcp_port = settings.get("tcp_port", 5000)
bind_address = settings.get("bind_address", "0.0.0.0")
def _make_sync_node_name_to_config(companion_name: str):
"""Return a callback that syncs node_name to config for this companion (binds name at creation)."""
def _sync(new_node_name: str) -> None:
try:
validated = validate_companion_node_name(new_node_name)
except ValueError:
return
companions = (self.config.get("identities") or {}).get("companions") or []
for entry in companions:
if entry.get("name") == companion_name:
if "settings" not in entry:
entry["settings"] = {}
entry["settings"]["node_name"] = validated
config_path = getattr(self, "config_path", None)
if config_path:
save_config(self.config, config_path)
break
return _sync
bridge = RepeaterCompanionBridge(
identity=identity,
packet_injector=self.router.inject_packet,
@@ -422,6 +442,7 @@ class RepeaterDaemon:
radio_config=radio_config,
sqlite_handler=sqlite_handler,
companion_hash=companion_hash_str,
on_prefs_saved=_make_sync_node_name_to_config(name),
)
# Load contacts from SQLite
+3 -1
View File
@@ -159,7 +159,9 @@ class APIEndpoints:
self.auth = AuthAPIEndpoints()
# Create nested companion object for /api/companion/* routes
self.companion = CompanionAPIEndpoints(daemon_instance, event_loop, self.config)
self.companion = CompanionAPIEndpoints(
daemon_instance, event_loop, self.config, self.config_manager
)
def _is_cors_enabled(self):
return self.config.get("web", {}).get("cors_enabled", False)
+32 -2
View File
@@ -16,6 +16,8 @@ from typing import Optional
import cherrypy
from repeater.companion.utils import validate_companion_node_name
from .auth.middleware import require_auth
logger = logging.getLogger("CompanionAPI")
@@ -29,10 +31,11 @@ class CompanionAPIEndpoints:
to the daemon's event loop via ``asyncio.run_coroutine_threadsafe``.
"""
def __init__(self, daemon_instance=None, event_loop=None, config=None):
def __init__(self, daemon_instance=None, event_loop=None, config=None, config_manager=None):
self.daemon_instance = daemon_instance
self.event_loop = event_loop
self.config = config or {}
self.config_manager = config_manager
# SSE clients: each gets a thread-safe queue
self._sse_clients: list[queue.Queue] = []
@@ -515,7 +518,34 @@ class CompanionAPIEndpoints:
name = body.get("advert_name", body.get("name", ""))
if not name:
raise cherrypy.HTTPError(400, "name required")
bridge.set_advert_name(name)
try:
validated_name = validate_companion_node_name(name)
except ValueError as e:
raise cherrypy.HTTPError(400, str(e)) from e
bridge.set_advert_name(validated_name)
# Optionally sync node_name to config.yaml so it survives restart
companion_name = body.get("companion_name")
if companion_name is None and getattr(self.daemon_instance, "identity_manager", None):
pubkey = bridge.get_public_key()
for reg_name, identity, _ in self.daemon_instance.identity_manager.get_identities_by_type(
"companion"
):
if identity.get_public_key() == pubkey:
companion_name = reg_name
break
if companion_name and self.config_manager:
companions = (self.config.get("identities") or {}).get("companions") or []
for entry in companions:
if entry.get("name") == companion_name:
if "settings" not in entry:
entry["settings"] = {}
entry["settings"]["node_name"] = validated_name
try:
if not self.config_manager.save_to_file():
logger.warning("Failed to save config after set_advert_name")
except Exception as e:
logger.warning("Error saving config after set_advert_name: %s", e)
break
return self._success({"name": bridge.prefs.node_name})
@cherrypy.expose