Merge current dev and preserve modem tokens

This commit is contained in:
yellowcooln committed 2026-08-24 08:50:44 -04:00
commit ada8ba98fb
10 files changed
+669 -37

No files matched your search

+55 -11
View File
@@ -5,12 +5,46 @@ from typing import Dict, Optional
from openhop_core.protocol import Identity
from openhop_core.protocol.constants import PUB_KEY_SIZE
# ACL roles come from openhop_core, which mirrors firmware
# ``src/helpers/ClientACL.h``: the role is the LOW TWO BITS of the permissions
# byte and ADMIN is 3 — it is not "the 0x02 bit".
#
# This import is deliberately fail-closed. A core without these symbols still
# builds the login reply's is_admin byte from ``permissions & 0x02``, which
# also matches READ_WRITE (2); pairing it with this module would silently
# announce a room server's read-write clients as admins. Refusing to start is
# the safe failure.
try:
from openhop_core.protocol.constants import (
PERM_ACL_ADMIN,
PERM_ACL_GUEST,
PERM_ACL_READ_ONLY,
PERM_ACL_READ_WRITE,
PERM_ACL_ROLE_MASK,
)
from openhop_core.protocol.constants import acl_is_admin as is_admin_permissions
from openhop_core.protocol.constants import acl_role as role_of
except ImportError as exc: # pragma: no cover - exercised by the install, not tests
raise ImportError(
"openhop_core is too old: it does not export PERM_ACL_* / acl_is_admin. "
"Install openhop_core with the ACL role fix (fix/login-perms or later) — "
"an older core encodes admin as the 0x02 bit and would announce "
"read-write clients as admins."
) from exc
logger = logging.getLogger("ACL")
PERM_ACL_GUEST = 0x01
PERM_ACL_ADMIN = 0x02
PERM_ACL_READ_WRITE = 0x01
PERM_ACL_ROLE_MASK = 0x03
_ROLE_NAMES = {
PERM_ACL_GUEST: "guest",
PERM_ACL_READ_ONLY: "read_only",
PERM_ACL_READ_WRITE: "read_write",
PERM_ACL_ADMIN: "admin",
}
def role_name(permissions: int) -> str:
"""Human-readable role name for logs and the web API."""
return _ROLE_NAMES[role_of(permissions)]
class ClientInfo:
@@ -28,10 +62,14 @@ class ClientInfo:
self.sync_since = 0 # For room servers - timestamp of last synced message
def is_admin(self) -> bool:
return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN
return is_admin_permissions(self.permissions)
def is_guest(self) -> bool:
return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST
return role_of(self.permissions) == PERM_ACL_GUEST
def role_name(self) -> str:
"""Role name ("guest"/"read_only"/"read_write"/"admin") for logs and the API."""
return role_name(self.permissions)
class ACL:
@@ -148,8 +186,8 @@ class ACL:
if self._is_replay(client, timestamp):
return False, 0
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
client.permissions |= PERM_ACL_GUEST
# No role normalisation needed: PERM_ACL_GUEST *is* role 0, so a
# client stored with no role bits already reads back as a guest.
return True, client.permissions
permissions = 0
@@ -161,8 +199,14 @@ class ACL:
permissions = PERM_ACL_ADMIN
logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'")
elif guest_pwd and password == guest_pwd:
permissions = PERM_ACL_READ_WRITE
logger.info(f"Guest password validated for '{target_identity_name or 'unknown'}'")
# Firmware splits the guest password by server type. simple_repeater
# grants GUEST (may fetch base telemetry, may not change settings);
# simple_room_server grants READ_WRITE (may post and read messages).
permissions = PERM_ACL_READ_WRITE if is_room_server else PERM_ACL_GUEST
logger.info(
f"Guest password validated for '{target_identity_name or 'unknown'}' "
f"(role={role_name(permissions)})"
)
else:
logger.info(f"Invalid password for '{target_identity_name or 'unknown'}'")
return False, 0
@@ -183,7 +227,7 @@ class ACL:
client.permissions &= ~PERM_ACL_ROLE_MASK
client.permissions |= permissions
logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}")
logger.info(f"Login success! Role: {client.role_name()}")
return True, client.permissions
def get_client(self, pub_key: bytes) -> Optional[ClientInfo]:
+4 -1
View File
@@ -419,7 +419,10 @@ class ProtocolRequestHelper:
result = bytearray()
for ci in identity_acl.get_all_clients():
if ci.permissions == 0:
continue # skip deleted entries
# Skip deleted entries. Firmware does the same check verbatim,
# so plain guests (role 0, no extra bits) are deliberately
# absent from the access list on both implementations.
continue
pubkey = ci.id.get_public_key()
result.extend(pubkey[:6]) # 6-byte pub_key prefix
result.append(ci.permissions & 0xFF)
+5 -5
View File
@@ -13,6 +13,7 @@ import time
from openhop_core.node.handlers.text import TextMessageHandler
from openhop_core.protocol import CryptoUtils, Identity
from .acl import is_admin_permissions
from .mesh_cli import MeshCLI
from .room_server import RoomServer
@@ -521,17 +522,16 @@ class TextHelper:
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
permissions = getattr(client_info, "permissions", 0)
PERM_ACL_ADMIN = 0x02
return (permissions & 0x02) == PERM_ACL_ADMIN
return is_admin_permissions(permissions)
return False
sender_pubkey = sender_client.id.get_public_key()
for client_info in identity_acl.get_all_clients():
if client_info.id.get_public_key() == sender_pubkey:
# Check admin bit (0x02 = PERM_ACL_ADMIN)
# Role is the low two bits with ADMIN == 3; a 0x02 bit test
# would also let READ_WRITE clients run admin CLI commands.
permissions = getattr(client_info, "permissions", 0)
PERM_ACL_ADMIN = 0x02
return (permissions & 0x02) == PERM_ACL_ADMIN
return is_admin_permissions(permissions)
return False
+18 -7
View File
@@ -26,6 +26,7 @@ from repeater.companion.utils import (
validate_companion_config_capacity,
)
from repeater.config import resolve_storage_dir
from repeater.handler_helpers.acl import role_name as acl_role_name
from repeater.modem_config import LEGACY_MODEM_RADIO_TYPES, normalize_modem_config
from repeater.policy_engine import PolicyEngine
from repeater.service_utils import get_buildroot_image_info
@@ -6925,7 +6926,13 @@ class APIEndpoints:
"public_key": pub_key[:8].hex() + "..." + pub_key[-4:].hex(),
"public_key_full": pub_key.hex(),
"address": address_bytes.hex(),
"permissions": "admin" if client.is_admin() else "guest",
# Accurate role name: with the guest password
# split (repeater=guest, room server=read_write)
# an "admin or guest" label would hide the
# difference between the non-admin roles.
# Read the byte rather than calling a method:
# ACL clients are duck-typed in several places.
"permissions": acl_role_name(getattr(client, "permissions", 0)),
"last_activity": client.last_activity,
"last_login_success": client.last_login_success,
"last_timestamp": client.last_timestamp,
@@ -7926,12 +7933,16 @@ class APIEndpoints:
imported_config = normalize_modem_config(imported_config)
# Redacted exports are intentionally safe to re-import. Preserve the
# currently configured modem token instead of saving the sentinel.
# Redacted exports and browser-originated partial modem updates are
# intentionally safe to re-import. Preserve the currently configured
# token when the incoming section carries the sentinel or omits the
# field; a real incoming token still replaces it.
current_config = normalize_modem_config(self.config, warn=False)
imported_tcp = imported_config.get("modem_tcp")
current_tcp = current_config.get("modem_tcp")
if isinstance(imported_tcp, dict) and imported_tcp.get("token") == "*** REDACTED ***":
if isinstance(imported_tcp, dict) and (
"token" not in imported_tcp or imported_tcp.get("token") == REDACTED_CONFIG_VALUE
):
if isinstance(current_tcp, dict) and "token" in current_tcp:
imported_tcp["token"] = current_tcp["token"]
else:
@@ -7951,9 +7962,9 @@ class APIEndpoints:
if not isinstance(entry, dict):
continue
imported_radio_tcp = entry.get("modem_tcp")
if not (
isinstance(imported_radio_tcp, dict)
and imported_radio_tcp.get("token") == "*** REDACTED ***"
if not isinstance(imported_radio_tcp, dict) or (
"token" in imported_radio_tcp
and imported_radio_tcp.get("token") != REDACTED_CONFIG_VALUE
):
continue
current_entry = current_radios_by_id.get(entry.get("id"))
+6 -4
View File
@@ -5243,12 +5243,14 @@ components:
example: "e1"
permissions:
type: string
enum: [admin, guest, read_only]
enum: [admin, read_write, read_only, guest]
description: |
Client permission level:
- admin: Full access
- guest: Limited access
Client ACL role (low two bits of the permissions byte, matching
MeshCore ClientACL.h):
- admin: Full access, including settings and CLI
- read_write: May send and receive messages (room server guests)
- read_only: Read-only access
- guest: Base telemetry only (repeater guests, read-only logins)
example: "admin"
last_activity:
type: integer