diff --git a/repeater/config_manager.py b/repeater/config_manager.py index 4db5dc2..af4bcf0 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -275,6 +275,14 @@ class ConfigManager: self.daemon.repeater_handler.reload_runtime_config() logger.info("Reloaded RepeaterHandler runtime config") + # Re-apply login security when the repeater section changed; the + # repeater ACL captures repeater.security at registration, so a + # saved password change must be pushed to the live ACL explicitly. + if "repeater" in sections and self.daemon: + login_helper = getattr(self.daemon, "login_helper", None) + if login_helper is not None and hasattr(login_helper, "refresh_repeater_security"): + login_helper.refresh_repeater_security(daemon_config) + # Also reload advert_helper config if repeater section changed if self.daemon and hasattr(self.daemon, "advert_helper") and self.daemon.advert_helper: if "repeater" in sections: diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index 552f1d8..63e90e5 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -33,6 +33,9 @@ class LoginHelper: self.handlers = {} self.acls = {} # Per-identity ACLs keyed by hash_byte + # The repeater identity's ACL, kept so live config updates can re-apply + # repeater.security without re-registering the identity. + self._repeater_acl = None self._pending_tasks = set() # Shared across all identities so the node's total anon-reply rate is # bounded (mirrors firmware anon_limiter: ~4 requests / 2 min). @@ -116,6 +119,8 @@ class LoginHelper: ) self.acls[hash_byte] = identity_acl + if identity_type != "room_server": + self._repeater_acl = identity_acl logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}") # Create auth callback that uses this identity's ACL @@ -180,6 +185,34 @@ class LoginHelper: logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}") + def refresh_repeater_security(self, config: dict = None) -> bool: + """Re-apply ``repeater.security`` from config to the live repeater ACL. + + The ACL captures its passwords at registration, so without this a saved + password change would only take effect after a restart. Room-server ACLs + keep their per-identity ``settings`` passwords and are not touched. + """ + acl = self._repeater_acl + if acl is None: + return False + + cfg = config if isinstance(config, dict) else self.config + security = (cfg or {}).get("repeater", {}).get("security", {}) or {} + + acl.admin_password = security.get("admin_password") or "" + acl.guest_password = security.get("guest_password") or "" + acl.allow_read_only = bool(security.get("allow_read_only", False)) + try: + acl.max_clients = int(security.get("max_clients", acl.max_clients)) + except (TypeError, ValueError): + logger.warning( + "Ignoring invalid repeater.security.max_clients=%r during security refresh", + security.get("max_clients"), + ) + + logger.info("Refreshed repeater ACL security settings from config") + return True + def _format_region_names(self) -> str: """Build the comma-separated region-names string for an anon regions reply. diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index e301231..4537676 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -55,6 +55,11 @@ class MeshCLI: self.config_manager.live_update_daemon(sections) return True + def _get_security_config(self): + """Return the repeater login security section (the one LoginHelper reads).""" + security = self.repeater_config.get("security") + return security if isinstance(security, dict) else {} + def _get_node_name(self) -> str: """Return the configured node name, preferring the newer key when present.""" return self.repeater_config.get("node_name") or self.repeater_config.get("name", "Unknown") @@ -463,15 +468,12 @@ class MeshCLI: if not new_password: return "Error: Password cannot be empty" - # Update security config - if "security" not in self.config: - self.config["security"] = {} - - self.config["security"]["password"] = new_password + # LoginHelper authenticates from repeater.security.admin_password. + self.repeater_config.setdefault("security", {})["admin_password"] = new_password # Save config and live update try: - if not self._save_config_and_apply(["security"]): + if not self._save_config_and_apply(["repeater"]): logger.error("Failed to save password: config save failed") return "Error: Failed to save config" return f"password now: {new_password}" @@ -552,7 +554,7 @@ class MeshCLI: return f"> {role}" elif param == "guest.password": - guest_pw = self.config.get("security", {}).get("guest_password", "") + guest_pw = self._get_security_config().get("guest_password") or "" return f"> {guest_pw}" elif param == "owner.info": @@ -560,7 +562,7 @@ class MeshCLI: return f"> {owner_info}" elif param == "allow.read.only": - allow = self.config.get("security", {}).get("allow_read_only", False) + allow = self._get_security_config().get("allow_read_only", False) return f"> {'on' if allow else 'off'}" elif param == "advert.interval": @@ -691,10 +693,9 @@ class MeshCLI: return "OK" elif key == "guest.password": - if "security" not in self.config: - self.config["security"] = {} - self.config["security"]["guest_password"] = value - if not self._save_config_and_apply(["security"]): + # LoginHelper authenticates from repeater.security.guest_password. + self.repeater_config.setdefault("security", {})["guest_password"] = value + if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" @@ -705,10 +706,11 @@ class MeshCLI: return "OK" elif key == "allow.read.only": - if "security" not in self.config: - self.config["security"] = {} - self.config["security"]["allow_read_only"] = value.lower() == "on" - if not self._save_config_and_apply(["security"]): + # LoginHelper reads repeater.security.allow_read_only. + self.repeater_config.setdefault("security", {})["allow_read_only"] = ( + value.lower() == "on" + ) + if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" diff --git a/tests/test_handler_helpers_mesh_cli.py b/tests/test_handler_helpers_mesh_cli.py index 873f7a7..febebc1 100644 --- a/tests/test_handler_helpers_mesh_cli.py +++ b/tests/test_handler_helpers_mesh_cli.py @@ -23,6 +23,12 @@ def _base_config(): "multi_acks": 2, "interference_threshold": -115, "agc_reset_interval": 8, + "security": { + "admin_password": "adminpw", + "guest_password": "guest", + "allow_read_only": True, + "max_clients": 5, + }, }, "radio": { "frequency": 915000000, @@ -32,7 +38,8 @@ def _base_config(): "tx_power": 22, }, "mesh": {"path_hash_mode": 0, "loop_detect": "minimal"}, - "security": {"guest_password": "guest", "allow_read_only": True}, + # Decoy: the legacy top-level section the CLI must no longer touch. + "security": {"guest_password": "stale-top-level", "allow_read_only": False}, } @@ -95,7 +102,10 @@ def test_cmd_password_save_success_failure_and_exception(): assert cli_ok._cmd_password("password ") == "Error: Password cannot be empty" assert cli_ok._cmd_password("password newpw") == "password now: newpw" - ok_mgr.live_update_daemon.assert_called_once_with(["security"]) + # The password must land where LoginHelper reads it, not the legacy top-level section. + assert cfg["repeater"]["security"]["admin_password"] == "newpw" + assert "password" not in cfg["security"] + ok_mgr.live_update_daemon.assert_called_once_with(["repeater"]) bad_mgr = _cfg_mgr(save_ok=False) cli_bad = MeshCLI("/tmp/cfg.yaml", _base_config(), bad_mgr) @@ -185,9 +195,12 @@ def test_cmd_set_updates_and_validation_errors(): assert cli._cmd_set("freq 868000000").startswith("OK") assert cli._cmd_set("tx 17") == "OK" assert cli._cmd_set("guest.password g") == "OK" + assert cfg["repeater"]["security"]["guest_password"] == "g" + assert cfg["security"]["guest_password"] == "stale-top-level" assert cli._cmd_set("owner.info Alice|Ops") == "OK" assert cfg["repeater"]["owner_info"] == "Alice\nOps" assert cli._cmd_set("allow.read.only off") == "OK" + assert cfg["repeater"]["security"]["allow_read_only"] is False assert cli._cmd_set("path.hash.mode 2") == "OK" assert cfg["mesh"]["path_hash_mode"] == 2 assert cli._cmd_set("path.hash.mode 3") == "Error: path.hash.mode must be 0, 1, or 2" @@ -452,3 +465,43 @@ def test_cmd_set_save_failure_reports_error_and_skips_live_update(): for command in ("af 2.0", "name x", "freq 915", "guest.password g", "flood.max 8"): assert cli._cmd_set(command) == "Error: Failed to save config" mgr.live_update_daemon.assert_not_called() + + +def test_cmd_get_reads_repeater_security_section(): + cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr()) + + # Values come from repeater.security, not the legacy top-level decoy. + assert cli._cmd_get("guest.password") == "> guest" + assert cli._cmd_get("allow.read.only") == "> on" + + +def test_cli_password_change_applies_to_live_repeater_acl(tmp_path): + """A CLI password change must reach the running LoginHelper ACL, so the + next login attempt authenticates against the new password.""" + from repeater.config_manager import ConfigManager + from repeater.handler_helpers.login import LoginHelper + + cfg = _base_config() + login_helper = LoginHelper(identity_manager=None, config=cfg) + identity = SimpleNamespace(get_public_key=lambda: b"\x42" + b"\x00" * 31) + login_helper.register_identity( + name="repeater", identity=identity, identity_type="repeater", config=cfg + ) + acl = login_helper.get_acl_for_identity(0x42) + assert acl.admin_password == "adminpw" + assert acl.guest_password == "guest" + + daemon = SimpleNamespace(config=cfg, login_helper=login_helper) + manager = ConfigManager( + config_path=str(tmp_path / "config.yaml"), config=cfg, daemon_instance=daemon + ) + cli = MeshCLI(str(tmp_path / "config.yaml"), cfg, manager) + + assert cli._cmd_password("password fresh-secret") == "password now: fresh-secret" + assert acl.admin_password == "fresh-secret" + + assert cli._cmd_set("guest.password fresh-guest") == "OK" + assert acl.guest_password == "fresh-guest" + + assert cli._cmd_set("allow.read.only off") == "OK" + assert acl.allow_read_only is False