mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
Merge current dev and preserve modem tokens
This commit is contained in:
commit
ada8ba98fb
10 files changed
+669
-37
No files matched your search
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user