mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-28 04:23:22 +02:00
fix(cli): store security settings where login authentication reads them
The password, guest.password, and allow.read.only commands wrote a top-level security section with a stale key name, while LoginHelper authenticates from repeater.security.admin_password/.guest_password/ .allow_read_only — so remote password changes never took effect. Point the set and get commands at the real subtree, and push repeater.security onto the live repeater ACL during a repeater-section live update: the ACL captures its passwords at registration, so without the refresh a saved change would still only apply after a restart. Room-server ACLs keep their per-identity settings passwords.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user