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:
agessaman
2026-07-19 07:09:42 -07:00
parent eaab0e5dca
commit eae42d9e19
4 changed files with 114 additions and 18 deletions
+8
View File
@@ -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
View File
@@ -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.
+18 -16
View File
@@ -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"
+55 -2
View File
@@ -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