From 2e7b3d6934891e0ab5816f056d20107d2fb9c1e0 Mon Sep 17 00:00:00 2001 From: malice Date: Thu, 9 Jul 2026 22:14:42 -0600 Subject: [PATCH] feat(meshcore): route MeshCore cells to room servers (open rooms) (#110) Extends MeshCore routing so a region_routes `mc` cell can target a room server, not just a `#`-channel. A room = a contact with type==3; a cell value of `room:` routes to it via the existing addressed DM path (send_msg to the room's pubkey), with an optional login for password-protected rooms; a bare cell value stays a channel broadcast (unchanged). Adds transport get_rooms()/login_to_room()/send_to_room_async, a GET /api/meshcore/rooms endpoint, per-room password storage in secrets_store (env MESHCORE_ROOM__PWD), and a routing-GUI channel-vs-room picker (rooms shown by name with a path indicator). Open rooms work end-to-end. Password-protected rooms need a follow-up: a backend endpoint to SET the per-room password from the GUI (the generic secrets API is allowlist-gated); the storage + login already exist. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/lib/api.ts | 18 + .../src/pages/MeshCoreRouting.tsx | 127 ++++- work/meshai/dashboard/api/mesh_send_routes.py | 19 + work/meshai/notifications/channels.py | 91 ++- work/meshai/secrets_store.py | 102 ++++ work/meshai/transport/composite_transport.py | 26 + work/meshai/transport/meshcore_transport.py | 138 ++++- work/tests/test_meshcore_rooms.py | 522 ++++++++++++++++++ 8 files changed, 1029 insertions(+), 14 deletions(-) create mode 100644 work/tests/test_meshcore_rooms.py diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index 510d919..dcdac6a 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -606,6 +606,24 @@ export async function getMeshcoreChannelsDetail(): Promise('/api/meshcore/channels/detail') } +// MeshCore room servers (type-3 contacts). A routing cell targets a room with +// the value ``room:`` (vs a bare channel name for channel targets). +// ``active:false`` / [] when MeshCore is not connected. +export interface MeshcoreRoom { + name: string | null + pubkey: string + prefix: string + path_established: boolean +} +export interface MeshcoreRooms { + active: boolean + rooms: MeshcoreRoom[] +} + +export async function getMeshcoreRooms(): Promise { + return fetchJson('/api/meshcore/rooms') +} + export interface MeshcoreContact { name: string | null pubkey: string diff --git a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx index 9a55037..9389d1e 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreRouting.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { Link } from 'react-router-dom' import { useDirty } from '@/context/DirtyContext' -import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react' +import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink, Home, Hash } from 'lucide-react' import { SeverityChannelMatrix, ListInput, @@ -14,6 +14,7 @@ import { type RegionCell, type RegionRoutes, } from './Notifications' +import { getMeshcoreRooms, type MeshcoreRoom } from '@/lib/api' // Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every // other (Meshtastic / Other-channels / general) field on the family. The @@ -89,6 +90,12 @@ function mergeRegionRoutesForMc( return { mt_enabled, mc_enabled, cells: newCells } } +// TODO: room password UI needs a backend secrets endpoint. secrets_store.py +// has set/get/clear_room_password() keyed by room pubkey, but NO HTTP route +// exposes them (the generic /api/secrets endpoint is gated to a fixed +// SECRET_LABELS allowlist, so it cannot set a dynamic per-room password). +// Open rooms route fine without a password; password-protected rooms need +// that endpoint before a set/clear affordance can be added here. export default function MeshCoreRouting() { const { setDirty } = useDirty() const [config, setConfig] = useState(null) @@ -99,6 +106,10 @@ export default function MeshCoreRouting() { const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [hasChanges, setHasChanges] = useState(false) + // MeshCore room servers, for the channel-vs-room cell picker. Rooms are + // targeted by writing the cell value as `room:`; channels stay bare. + const [rooms, setRooms] = useState([]) + const [roomsActive, setRoomsActive] = useState(false) // Local map: family key -> explicitly toggled on/off for region expand. const [regionExpandedMap, setRegionExpandedMap] = useState>({}) @@ -129,6 +140,22 @@ export default function MeshCoreRouting() { fetchConfig() }, [fetchConfig]) + // Load MeshCore room servers once; degrade quietly if MeshCore is offline. + const fetchRooms = useCallback(async () => { + try { + const res = await getMeshcoreRooms() + setRooms(res.active ? res.rooms : []) + setRoomsActive(res.active) + } catch { + setRooms([]) + setRoomsActive(false) + } + }, []) + + useEffect(() => { + fetchRooms() + }, [fetchRooms]) + useEffect(() => { if (config && originalConfig) { setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) @@ -152,6 +179,14 @@ export default function MeshCoreRouting() { }) } + // A cell value is either a bare channel NAME or `room:`. + const ROOM_PREFIX = 'room:' + const isRoomValue = (v: string | null | undefined): boolean => + typeof v === 'string' && v.startsWith(ROOM_PREFIX) + const roomPubkeyOf = (v: string): string => v.slice(ROOM_PREFIX.length) + const roomByPubkey = (pubkey: string): MeshcoreRoom | undefined => + rooms.find((r) => r.pubkey === pubkey) + const setMcForRegion = (family: string, region: string, mc: string | null) => { if (!config) return const existing: RegionCell = config.region_routes?.cells?.[family]?.[region] ?? { @@ -428,19 +463,89 @@ export default function MeshCoreRouting() { const cell: RegionCell = familyCells[region] ?? { mt: null, mc: null, min_severity: 'routine', enabled: true, } + const mcVal = cell.mc ?? '' + const targetsRoom = isRoomValue(mcVal) + const room = targetsRoom ? roomByPubkey(roomPubkeyOf(mcVal)) : undefined return (
{region} - { - const v = e.target.value - setMcForRegion(key, region, v === '' ? null : v) - }} - placeholder="channel" - className="w-28 px-2 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-accent" - /> + {/* Destination type: channel (bare name) vs room (room:) */} +
+ +
+ ) : ( + MeshCore not connected + ) + ) : ( + { + const v = e.target.value + setMcForRegion(key, region, v === '' ? null : v) + }} + placeholder="channel" + className="w-40 px-2 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-accent" + /> + )}
) })} diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index bd44a1d..cea79a5 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -57,6 +57,25 @@ async def meshcore_channels_detail(request: Request): return {"active": False, "channels": []} +@router.get("/meshcore/rooms") +async def meshcore_rooms(request: Request): + """List MeshCore room servers if a meshcore transport is connected. + + Returns {"active": bool, "rooms": [{"name", "pubkey", "prefix", + "path_established"}]}. Parallels /meshcore/channels — the frontend uses + this to offer room targets for the ``room:`` routing cell. + """ + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is not None and getattr(mc, "connected", False): + try: + rooms = list(mc.get_rooms()) + except Exception: + rooms = [] + return {"active": True, "rooms": rooms} + return {"active": False, "rooms": []} + + @router.get("/meshcore/contacts") async def meshcore_contacts(request: Request): """Roster of known MeshCore contacts if a meshcore transport is connected.""" diff --git a/work/meshai/notifications/channels.py b/work/meshai/notifications/channels.py index 5180c92..2214b73 100644 --- a/work/meshai/notifications/channels.py +++ b/work/meshai/notifications/channels.py @@ -21,6 +21,27 @@ from meshai.notifications.renderers import MeshRenderer, EmailRenderer, WebhookR logger = logging.getLogger(__name__) +# A MeshCore routing cell value of ``room:`` means "route this send to +# the room server with this pubkey" (an addressed send, login-if-password), +# instead of a channel broadcast. A bare value stays a channel NAME. The parse +# lives in ONE place: parse_meshcore_room() below, used by MeshCoreBroadcastChannel. +MESHCORE_ROOM_PREFIX = "room:" + + +def parse_meshcore_room(cell_value: Optional[str]) -> Optional[str]: + """Return the room pubkey if *cell_value* is a ``room:`` cell, else None. + + A bare (non-prefixed) value is a channel NAME and yields None so callers + fall through to the channel-broadcast path unchanged. An empty pubkey + (``"room:"`` with nothing after) also yields None (nothing to route to). + """ + if not cell_value or not isinstance(cell_value, str): + return None + if not cell_value.startswith(MESHCORE_ROOM_PREFIX): + return None + pubkey = cell_value[len(MESHCORE_ROOM_PREFIX):].strip() + return pubkey or None + class NotificationChannel(ABC): """Base class for notification delivery channels.""" @@ -198,8 +219,49 @@ class MeshCoreBroadcastChannel(NotificationChannel): # Single-transport: check for an explicit transport_name tag. return getattr(self._connector, "transport_name", None) == "meshcore" + @staticmethod + def _room_password(pubkey: str) -> Optional[str]: + """Look up this room's configured password (or None) via secrets_store. + + Kept as a thin wrapper so the lookup convention lives with the secrets + model and the import stays lazy (secrets_store touches the filesystem). + Never raises — a missing/unreadable secret degrades to "open room". + """ + try: + from meshai.secrets_store import get_room_password + return get_room_password(pubkey) + except Exception: + logger.debug("meshcore_broadcast: room password lookup failed", exc_info=True) + return None + + async def _deliver_to_room(self, pubkey: str, alert: "NotificationPayload") -> bool: + """Route this alert to a room server (addressed send, login-if-password).""" + password = self._room_password(pubkey) + # If payload already has chunk metadata (from digest), send as-is; + # otherwise render to chunks exactly like the channel-broadcast path. + if alert.chunk_index is not None: + chunks = [alert.message or ""] + else: + chunks = self._renderer.render(alert) + success = True + for chunk in chunks: + ok = await self._connector.send_message_async( + text=chunk, + destination=None, + meshcore_room=pubkey, + meshcore_room_password=password, + transport="meshcore", + ) + if not ok: + success = False + logger.info( + "MeshCore room send %d chunk(s) to room %s (success=%s)", + len(chunks), pubkey[:12], success, + ) + return success + async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool: - """Send alert to MeshCore channel.""" + """Send alert to MeshCore channel (or room server if cell is ``room:``).""" if not self._connector: logger.warning("No mesh connector available for meshcore_broadcast") return False @@ -214,6 +276,17 @@ class MeshCoreBroadcastChannel(NotificationChannel): ) return False + # Room-server routing: a ``room:`` cell delivers via addressed + # send (login-if-password) instead of a channel broadcast. A bare cell + # is a channel name -> None -> the unchanged broadcast path below. + room_pubkey = parse_meshcore_room(self._meshcore_channel) + if room_pubkey is not None: + try: + return await self._deliver_to_room(room_pubkey, alert) + except Exception as e: + logger.error("Failed to MeshCore room-send alert: %s", e) + return False + try: # If payload already has chunk metadata (from digest), use message directly if alert.chunk_index is not None: @@ -267,11 +340,25 @@ class MeshCoreBroadcastChannel(NotificationChannel): } async def deliver_test(self, message: str) -> tuple[bool, str]: - """Deliver a specific test message to the MeshCore channel.""" + """Deliver a specific test message to the MeshCore channel (or room).""" if not self._connector: return False, "Not connected" if not self._meshcore_channel: return False, "No MeshCore channel configured" + room_pubkey = parse_meshcore_room(self._meshcore_channel) + if room_pubkey is not None: + try: + ok = bool(await self._connector.send_message_async( + text=message, + destination=None, + meshcore_room=room_pubkey, + meshcore_room_password=self._room_password(room_pubkey), + transport="meshcore", + )) + return ok, (f"Sent to MeshCore room {room_pubkey[:12]}" if ok + else "MeshCore room send returned False") + except Exception as e: + return False, f"MeshCore room send failed: {e}" try: ok = bool(await self._connector.send_message_async( text=message, diff --git a/work/meshai/secrets_store.py b/work/meshai/secrets_store.py index 1fe3b3b..7b04fff 100644 --- a/work/meshai/secrets_store.py +++ b/work/meshai/secrets_store.py @@ -140,3 +140,105 @@ def list_secrets(config_dir: Path = Path("/data/config")) -> list[dict]: } for var in _managed_vars() ] + + +# --------------------------------------------------------------------------- +# MeshCore room-server passwords (dynamic, per-room secrets) +# --------------------------------------------------------------------------- +# +# Unlike the fixed SECRET_LABELS allowlist above, room passwords are keyed by +# the room server's public key, so the env var name is DERIVED from the pubkey +# rather than drawn from a static vocabulary. Each password is stored in the +# same secrets .env file under a per-room var name: +# +# MESHCORE_ROOM__PWD +# +# where is the first ROOM_PUBKEY_PREFIX_LEN hex chars of the room's +# public key, uppercased. The transport resolves the password by pubkey at +# send time (login-before-send); the GUI/API sets it via set_room_password(). +# +# The routing cell NEVER holds the password — only ``room:``. + +ROOM_PUBKEY_PREFIX_LEN = 12 +_ROOM_PWD_PREFIX = "MESHCORE_ROOM_" +_ROOM_PWD_SUFFIX = "_PWD" + + +def room_pwd_env_var(pubkey: str) -> str: + """Derive the .env var name for a room server's password from its pubkey. + + ``pubkey`` may be a full 32-byte hex key or a shorter prefix; the first + ROOM_PUBKEY_PREFIX_LEN hex chars are used (uppercased) so a cell holding a + prefix and a cell holding the full key resolve to the SAME secret. + + Raises ValueError on an empty pubkey. + """ + pk = (pubkey or "").strip() + if not pk: + raise ValueError("room pubkey must be non-empty") + prefix = pk[:ROOM_PUBKEY_PREFIX_LEN].upper() + return f"{_ROOM_PWD_PREFIX}{prefix}{_ROOM_PWD_SUFFIX}" + + +def get_room_password( + pubkey: str, config_dir: Path = Path("/data/config") +) -> str | None: + """Resolve a room server's password by pubkey, or None if none is set. + + Resolution mirrors config_loader._interpolate_env_vars: os.environ takes + precedence over the .env file. Returns the raw value for internal use + (transport login-before-send) — do NOT expose this via a status API; the + GUI status path must use ``room_password_is_set`` (booleans only). + """ + try: + var = room_pwd_env_var(pubkey) + except ValueError: + return None + val = os.environ.get(var) + if val: + return val + path = secret_env_path(config_dir) + try: + env_file = dotenv_values(path) if path.exists() else {} + except Exception: + env_file = {} + val = env_file.get(var) + return val or None + + +def room_password_is_set( + pubkey: str, config_dir: Path = Path("/data/config") +) -> bool: + """True if a password is configured for this room (never returns the value).""" + return get_room_password(pubkey, config_dir) is not None + + +def set_room_password( + pubkey: str, value: str, config_dir: Path = Path("/data/config") +) -> None: + """Store a room server's password (GUI/API write path). + + Keyed by pubkey via room_pwd_env_var(). Raises ValueError on empty pubkey. + An empty value deletes the entry (mirrors "clear the password"). + """ + var = room_pwd_env_var(pubkey) + path = secret_env_path(config_dir) + if value: + set_key(str(path), var, value) + else: + try: + unset_key(str(path), var) + except (KeyError, FileNotFoundError): + pass + + +def delete_room_password( + pubkey: str, config_dir: Path = Path("/data/config") +) -> None: + """Remove a room server's password from the secrets .env (no-op if absent).""" + var = room_pwd_env_var(pubkey) + path = secret_env_path(config_dir) + try: + unset_key(str(path), var) + except (KeyError, FileNotFoundError): + pass diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index 7e2b73e..9edebe3 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -97,6 +97,12 @@ class CompositeTransport(MeshTransport): child = self.meshcore_child() return child.get_contacts() if child is not None else [] + def get_rooms(self) -> List[dict]: + """Passthrough to the MeshCore child's room-server list; [] if no meshcore child.""" + child = self.meshcore_child() + get_rooms = getattr(child, "get_rooms", None) if child is not None else None + return get_rooms() if get_rooms is not None else [] + def self_info(self) -> dict: """Passthrough to the MeshCore child's self/connection status; {connected: False} if no meshcore child.""" child = self.meshcore_child() @@ -254,13 +260,33 @@ class CompositeTransport(MeshTransport): channel: int = 0, transport: Optional[str] = None, meshcore_channel: Optional[str] = None, + meshcore_room: Optional[str] = None, + meshcore_room_password: Optional[str] = None, ) -> bool: """Async send through per-child queues, with the same routing logic as send_message(). Each child's send_message_async() goes through that child's serialized queue, so Meshtastic and MeshCore sends are each independently paced. For broadcasts the two are awaited sequentially (Meshtastic first, then MeshCore). + + ``meshcore_room`` (a room-server pubkey) routes ONLY to the MeshCore + child's room send (login-if-password + addressed send). It never + touches Meshtastic and is mutually exclusive with a channel broadcast. """ + if meshcore_room: + child = self.meshcore_child() + if child is None or not child.connected: + return False + try: + return await child.send_message_async( + text, + destination=None, + meshcore_room=meshcore_room, + meshcore_room_password=meshcore_room_password, + ) + except Exception as exc: + logger.error("CompositeTransport: async room send raised: %s", exc) + return False if destination is None: return await self._broadcast_async(text, channel, meshcore_channel=meshcore_channel, transport=transport) diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index 867f274..3515c0f 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -135,6 +135,12 @@ class MeshCoreTransport(MeshTransport): # --- per-radio send queue (on the MC dedicated loop) --- self._mc_send_queue: Optional[asyncio.Queue] = None self._mc_drain_task: Optional[asyncio.Task] = None + # Room servers we currently hold a LOGIN_SUCCESS session for, keyed by + # the room's pubkey (as passed to login_to_room). A password-protected + # room must be logged into before an addressed send is accepted; we + # login once and remember it, clearing the entry on LOGIN_FAILED so the + # next send re-attempts the login. Populated only by login_to_room. + self._logged_in_rooms: set[str] = set() # ------------------------------------------------------------------ # Internal helpers @@ -656,16 +662,27 @@ class MeshCoreTransport(MeshTransport): channel: int = 0, transport: Optional[str] = None, meshcore_channel: Optional[str] = None, + meshcore_room: Optional[str] = None, + meshcore_room_password: Optional[str] = None, ) -> bool: """Async send through the MC per-radio queue (called from the main loop). Enqueues the job on the MC loop's queue and awaits the actual send result via a concurrent.futures.Future bridge. + + ``meshcore_room`` (a room-server pubkey) routes to send_to_room_async + (login-if-password + addressed send) INSTEAD of a channel broadcast; + it shares the same queue so room sends are paced like every other send. """ if self._mc is None or not self._connected: return False if self._mc_send_queue is None or self._loop is None or not self._loop.is_running(): # Queue not started yet (e.g. initial advert at connect) — fall back. + # Room sends are only issued post-connect (queue up), so the sync + # fallback covers only DM/broadcast; a room target degrades to no-op. + if meshcore_room: + logger.debug("MC: room send before queue start; skipping") + return False loop = asyncio.get_event_loop() return await loop.run_in_executor( None, @@ -674,7 +691,12 @@ class MeshCoreTransport(MeshTransport): cfut: concurrent.futures.Future = concurrent.futures.Future() - if destination: + if meshcore_room: + async def _job() -> bool: + return await self.send_to_room_async( + meshcore_room, text, password=meshcore_room_password + ) + elif destination: async def _job() -> bool: return await self._do_mc_dm_send_async(text, destination) else: @@ -856,6 +878,120 @@ class MeshCoreTransport(MeshTransport): }) return roster + # A MeshCore ROOM SERVER is a contact whose ``type`` is ROOM (3) in the + # firmware CONTACT_TYPENAMES table [NONE, CLI, REP, ROOM, SENS]. We route + # to a room via the DM primitive (send_msg to its pubkey), so a room is + # just an addressable contact of this type. + ROOM_CONTACT_TYPE = 3 + + def get_rooms(self) -> list[dict]: + """Room servers on the companion: [{name, pubkey, prefix, path_established}]. + + Filters ``get_contacts()`` (the same roster the DM path resolves + against) to contacts of type ROOM (3). ``prefix`` is the 12-hex-char + pubkey prefix used for routing cells / password keys; + ``path_established`` is True when the companion already holds a direct + route (out_path_len >= 0), False when the next send must flood/discover. + Returns [] if not connected. Mirrors ``known_channels()`` in intent + (enumerate routable destinations) but for rooms rather than channels. + """ + rooms: list[dict] = [] + for c in self.get_contacts(): + if c.get("type") != self.ROOM_CONTACT_TYPE: + continue + pubkey = c.get("pubkey") or "" + try: + out_path_len = int(c.get("out_path_len", -1)) + except (TypeError, ValueError): + out_path_len = -1 + rooms.append({ + "name": c.get("name"), + "pubkey": pubkey, + "prefix": pubkey[:12] if pubkey else "", + "path_established": out_path_len >= 0, + }) + return rooms + + async def get_rooms_async(self) -> list[dict]: + """Async variant of get_rooms() for callers already on an event loop. + + get_contacts() reads the lib's cached ``contacts`` mirror; the only + blocking step is an optional ensure_contacts() refresh, run on the MC + loop here rather than via _run_coro so there is no cross-loop hop. + Returns [] if not connected. + """ + if self._mc is None or not self._connected: + return [] + try: + ensure = getattr(self._mc, "ensure_contacts", None) + if ensure is not None: + await ensure() + except Exception: + pass + return self.get_rooms() + + async def login_to_room(self, pubkey: str, pwd: str) -> bool: + """Log in to a password-protected room server; track the session. + + Wraps ``commands.send_login_sync(dst_pubkey, pwd)`` and awaits the + LOGIN_SUCCESS / LOGIN_FAILED outcome. On success the room pubkey is + added to ``self._logged_in_rooms`` so subsequent sends skip re-login; + on failure (or error) the tracked state is cleared so the next send + re-attempts the login. Never raises — returns False on any error. + + Runs on the MC loop (call from within it, e.g. from send_to_room_async). + """ + if self._mc is None: + return False + contact = await self._resolve_contact_async(pubkey) + if contact is None: + logger.warning("MC: cannot login to room %s — contact not resolved", pubkey) + self._logged_in_rooms.discard(pubkey) + return False + label = contact.get("adv_name") or pubkey + try: + event = await asyncio.wait_for( + self._mc.commands.send_login_sync(contact, pwd), timeout=15 + ) + except Exception as exc: + logger.warning("MC: room login to %s raised: %s", label, exc) + self._logged_in_rooms.discard(pubkey) + return False + # send_login_sync resolves to the LOGIN_SUCCESS / LOGIN_FAILED event. + is_err = getattr(event, "is_error", None) + if event is None or (callable(is_err) and event.is_error()): + logger.warning("MC: room login to %s FAILED", label) + self._logged_in_rooms.discard(pubkey) + return False + logger.info("MC: room login to %s succeeded", label) + self._logged_in_rooms.add(pubkey) + return True + + async def send_to_room_async( + self, pubkey: str, text: str, password: Optional[str] = None + ) -> bool: + """Send *text* to a room server by pubkey (login first if password-protected). + + A room send reuses the addressed-send machinery exactly: it delegates + to ``_do_mc_dm_send_async`` (resolve contact -> send_msg -> ACK / path + discovery / resend), so path establishment and ACK handling are shared + with normal DMs and channel broadcast is untouched. + + If *password* is provided and we do not already hold a session for this + room, log in first; on a login failure surface it (return False) so the + caller does not silently send to a room that will reject the message. + A password-protected room whose session was cleared (prior LOGIN_FAILED) + re-attempts the login here. Runs on the MC loop. + """ + if self._mc is None: + return False + if password: + if pubkey not in self._logged_in_rooms: + ok = await self.login_to_room(pubkey, password) + if not ok: + return False + return await self._do_mc_dm_send_async(text, pubkey) + def self_info(self) -> dict: """Companion self/connection status. {connected: False} if not connected.""" if self._mc is None or not self._connected: diff --git a/work/tests/test_meshcore_rooms.py b/work/tests/test_meshcore_rooms.py new file mode 100644 index 0000000..3dca65e --- /dev/null +++ b/work/tests/test_meshcore_rooms.py @@ -0,0 +1,522 @@ +"""Tests for MeshCore room-server routing (backend capability). + +Covers, end to end without a radio: + * MeshCoreTransport.get_rooms() — filter contacts to type==3, room shape + * MeshCoreTransport.login_to_room() — send_login_sync + LOGIN_SUCCESS/FAILED + * MeshCoreTransport.send_to_room_async — login-if-password + addressed send + * secrets_store room-password convention (derive / set / get / delete) + * channels.parse_meshcore_room() — room: vs bare channel name + * MeshCoreBroadcastChannel routing — room cell -> room send (NOT send_chan_msg), + channel cell -> broadcast (regression), + password room -> login before send. + +The meshcore lib is mocked via sys.modules (same pattern as the existing +transport test module), so no lib or socket is required. +""" + +import asyncio +import sys +import threading +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Fake meshcore module (register before production imports) +# --------------------------------------------------------------------------- + +def _build_fake_meshcore(): + mod = types.ModuleType("meshcore") + + class EventType: + CONTACT_MSG_RECV = "CONTACT_MSG_RECV" + CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV" + DISCONNECTED = "DISCONNECTED" + CONNECTED = "CONNECTED" + ACK = "ACK" + NEW_CONTACT = "NEW_CONTACT" + + mod.EventType = EventType + + # Superset of the fake used by test_meshcore_transport.py: because test + # modules share one interpreter and register via setdefault(), whichever + # module is collected FIRST wins. This fake must therefore satisfy the + # transport module's advert/connect tests too (send_advert, create_tcp, + # auto-fetch, disconnect) — plus send_login_sync for room login here. + class _FakeMeshCore: + self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"} + contacts = {} + + async def start_auto_message_fetching(self): + pass + + async def stop_auto_message_fetching(self): + pass + + async def disconnect(self): + pass + + async def ensure_contacts(self, follow=False): + return True + + def subscribe(self, event_type, callback): + pass + + def get_contact_by_key_prefix(self, prefix): + return None + + @classmethod + async def create_tcp(cls, host, port, + auto_reconnect=True, max_reconnect_attempts=5): + return cls() + + class commands: + @staticmethod + async def send_chan_msg(chan_idx, text): + r = MagicMock() + r.is_error.return_value = False + return r + + @staticmethod + async def send_msg(dst, text): + r = MagicMock() + r.is_error.return_value = False + return r + + @staticmethod + async def send_login_sync(dst, pwd): + r = MagicMock() + r.is_error.return_value = False + return r + + @staticmethod + async def send_advert(flood=False): + pass + + @staticmethod + async def set_autoadd_config(value): + r = MagicMock() + r.is_error.return_value = False + return r + + mod.MeshCore = _FakeMeshCore + return mod + + +sys.modules.setdefault("meshcore", _build_fake_meshcore()) + + +# --------------------------------------------------------------------------- +# Production imports +# --------------------------------------------------------------------------- + +from meshai.config import ConnectionConfig # noqa: E402 +from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402 +from meshai import secrets_store # noqa: E402 +from meshai.notifications import channels as ch # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers (mirror test_meshcore_transport.py) +# --------------------------------------------------------------------------- + +def _mc_config(**overrides): + cfg = ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050) + for k, v in overrides.items(): + setattr(cfg, k, v) + return cfg + + +def _transport_with_mock_mc(): + """MeshCoreTransport with a MagicMock _mc and a real dedicated loop thread.""" + t = MeshCoreTransport(_mc_config()) + mc = MagicMock() + mc.get_contact_by_key_prefix.return_value = None + t._mc = mc + t._connected = True + loop = asyncio.new_event_loop() + t._loop = loop + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + t._loop_thread = thread + return t, mc, loop + + +def _cleanup(t): + try: + if t._loop and t._loop.is_running(): + t._loop.call_soon_threadsafe(t._loop.stop) + if t._loop_thread and t._loop_thread.is_alive(): + t._loop_thread.join(timeout=2.0) + except Exception: + pass + + +def _run(t, coro): + """Run a coroutine on the transport's dedicated loop and return its result.""" + return asyncio.run_coroutine_threadsafe(coro, t._loop).result(timeout=5) + + +# A room server contact (type==3) and a couple of non-room contacts. +_ROOM_PUBKEY = "cc11" + "d" * 60 # 64-hex +_SAMPLE_CONTACTS = { + "cc11": { + "adv_name": "Boise Room", "public_key": _ROOM_PUBKEY, + "type": 3, "last_advert": 3000, "adv_lat": 43.6, "adv_lon": -116.2, + "out_path_len": 4, + }, + "aa11": { + "adv_name": "Repeater One", "public_key": "aa11" + "e" * 60, + "type": 2, "last_advert": 1000, "out_path_len": 2, + }, + "bb22": { + "adv_name": "Chat Node", "public_key": "bb22" + "f" * 60, + "type": 0, "last_advert": 2000, "out_path_len": -1, + }, +} + + +# =========================================================================== +# 1. get_rooms() +# =========================================================================== + +class TestGetRooms: + def test_filters_to_type_3_with_room_shape(self): + t, mc, _ = _transport_with_mock_mc() + try: + mc.ensure_contacts = AsyncMock(return_value=None) + mc.contacts = dict(_SAMPLE_CONTACTS) + rooms = t.get_rooms() + assert len(rooms) == 1, "only the type==3 contact is a room" + room = rooms[0] + assert room == { + "name": "Boise Room", + "pubkey": _ROOM_PUBKEY, + "prefix": _ROOM_PUBKEY[:12], + "path_established": True, # out_path_len 4 >= 0 + } + finally: + _cleanup(t) + + def test_path_established_false_when_no_path(self): + t, mc, _ = _transport_with_mock_mc() + try: + mc.ensure_contacts = AsyncMock(return_value=None) + mc.contacts = { + "cc11": { + "adv_name": "Flood Room", "public_key": _ROOM_PUBKEY, + "type": 3, "out_path_len": -1, + } + } + rooms = t.get_rooms() + assert rooms[0]["path_established"] is False + finally: + _cleanup(t) + + def test_no_rooms_when_none_are_type_3(self): + t, mc, _ = _transport_with_mock_mc() + try: + mc.ensure_contacts = AsyncMock(return_value=None) + mc.contacts = {k: v for k, v in _SAMPLE_CONTACTS.items() if k != "cc11"} + assert t.get_rooms() == [] + finally: + _cleanup(t) + + def test_returns_empty_when_not_connected(self): + t = MeshCoreTransport(_mc_config()) + assert t.get_rooms() == [] + + +# =========================================================================== +# 2. login_to_room() + send_to_room_async() +# =========================================================================== + +class TestRoomLoginAndSend: + def _wire_room(self, mc): + room = dict(_SAMPLE_CONTACTS["cc11"]) + mc.get_contact_by_key_prefix.return_value = room + # Fast path: send_msg carries an expected_ack, and the dispatcher + # returns a matching ACK, so delivery succeeds with a SINGLE send_msg + # (no discovery/resend leg) — keeps the login assertions clean. + ok = MagicMock() + ok.is_error.return_value = False + ok.payload = {"type": 0, "expected_ack": b"\x01\x02\x03\x04"} + mc.commands.send_msg = AsyncMock(return_value=ok) + mc.dispatcher.wait_for_event = AsyncMock(return_value=MagicMock()) # ACK + return room + + def test_open_room_send_no_login(self): + """No password -> send_msg to the room pubkey, send_login_sync NOT called.""" + t, mc, _ = _transport_with_mock_mc() + try: + room = self._wire_room(mc) + mc.commands.send_login_sync = AsyncMock() + ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "hi room", password=None)) + assert ok is True + mc.commands.send_login_sync.assert_not_awaited() + mc.commands.send_msg.assert_awaited_with(room, "hi room") + assert _ROOM_PUBKEY not in t._logged_in_rooms + finally: + _cleanup(t) + + def test_password_room_logs_in_before_send(self): + """A password -> send_login_sync (LOGIN_SUCCESS) THEN send_msg; room tracked.""" + t, mc, _ = _transport_with_mock_mc() + try: + room = self._wire_room(mc) + login_ok = MagicMock() + login_ok.is_error.return_value = False + mc.commands.send_login_sync = AsyncMock(return_value=login_ok) + + ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "secret hi", password="pw")) + assert ok is True + mc.commands.send_login_sync.assert_awaited_once_with(room, "pw") + mc.commands.send_msg.assert_awaited_with(room, "secret hi") + assert _ROOM_PUBKEY in t._logged_in_rooms + finally: + _cleanup(t) + + def test_login_reused_on_second_send(self): + """Already-logged-in room -> no second login on the next send.""" + t, mc, _ = _transport_with_mock_mc() + try: + self._wire_room(mc) + login_ok = MagicMock() + login_ok.is_error.return_value = False + mc.commands.send_login_sync = AsyncMock(return_value=login_ok) + + _run(t, t.send_to_room_async(_ROOM_PUBKEY, "one", password="pw")) + _run(t, t.send_to_room_async(_ROOM_PUBKEY, "two", password="pw")) + mc.commands.send_login_sync.assert_awaited_once() # login only once + finally: + _cleanup(t) + + def test_login_failure_surfaces_and_no_send(self): + """LOGIN_FAILED -> send_to_room_async returns False and does NOT send_msg.""" + t, mc, _ = _transport_with_mock_mc() + try: + self._wire_room(mc) + login_err = MagicMock() + login_err.is_error.return_value = True # LOGIN_FAILED + mc.commands.send_login_sync = AsyncMock(return_value=login_err) + + ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "nope", password="bad")) + assert ok is False, "login failure must surface as a failed send" + mc.commands.send_msg.assert_not_awaited() + assert _ROOM_PUBKEY not in t._logged_in_rooms + finally: + _cleanup(t) + + def test_login_failure_then_retry_relogins(self): + """After a failed login the state is cleared, so the next send re-logins.""" + t, mc, _ = _transport_with_mock_mc() + try: + self._wire_room(mc) + login_err = MagicMock(); login_err.is_error.return_value = True + login_ok = MagicMock(); login_ok.is_error.return_value = False + mc.commands.send_login_sync = AsyncMock(side_effect=[login_err, login_ok]) + + first = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "a", password="pw")) + second = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "b", password="pw")) + assert first is False + assert second is True + assert mc.commands.send_login_sync.await_count == 2 # re-login attempted + finally: + _cleanup(t) + + +# =========================================================================== +# 3. send_message_async(meshcore_room=...) routes to room, not channel +# =========================================================================== + +class TestSendMessageAsyncRoom: + def test_meshcore_room_calls_send_msg_not_send_chan_msg(self): + """A room target goes through the queue -> send_msg; send_chan_msg untouched.""" + t, mc, _ = _transport_with_mock_mc() + try: + room = dict(_SAMPLE_CONTACTS["cc11"]) + mc.get_contact_by_key_prefix.return_value = room + ok = MagicMock(); ok.is_error.return_value = False + ok.payload = {"type": 0, "expected_ack": b"\x01\x02\x03\x04"} + mc.commands.send_msg = AsyncMock(return_value=ok) + mc.commands.send_chan_msg = AsyncMock() + mc.dispatcher.wait_for_event = AsyncMock(return_value=MagicMock()) + # Arm the send queue on the MC loop (send_message_async requires it). + asyncio.run_coroutine_threadsafe( + _arm_queue(t), t._loop + ).result(timeout=5) + + result = _run( + t, t.send_message_async("hi", destination=None, meshcore_room=_ROOM_PUBKEY) + ) + assert result is True + mc.commands.send_msg.assert_awaited_with(room, "hi") + mc.commands.send_chan_msg.assert_not_awaited() + finally: + t._cancel_mc_queue() # stop the drain task before the loop closes + _cleanup(t) + + +async def _arm_queue(t): + t._start_mc_queue() + + +# =========================================================================== +# 4. secrets_store room-password convention +# =========================================================================== + +class TestRoomPasswordSecrets: + def test_env_var_derivation_uses_12_hex_prefix_upper(self): + var = secrets_store.room_pwd_env_var(_ROOM_PUBKEY) + assert var == "MESHCORE_ROOM_" + _ROOM_PUBKEY[:12].upper() + "_PWD" + + def test_prefix_and_full_key_resolve_same_var(self): + full = secrets_store.room_pwd_env_var(_ROOM_PUBKEY) + prefix = secrets_store.room_pwd_env_var(_ROOM_PUBKEY[:12]) + assert full == prefix + + def test_empty_pubkey_raises(self): + with pytest.raises(ValueError): + secrets_store.room_pwd_env_var("") + + def test_set_get_delete_roundtrip(self, tmp_path): + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None + assert secrets_store.room_password_is_set(_ROOM_PUBKEY, cfg_dir) is False + + secrets_store.set_room_password(_ROOM_PUBKEY, "topsecret", cfg_dir) + assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) == "topsecret" + assert secrets_store.room_password_is_set(_ROOM_PUBKEY, cfg_dir) is True + + secrets_store.delete_room_password(_ROOM_PUBKEY, cfg_dir) + assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None + + def test_set_empty_value_clears(self, tmp_path): + cfg_dir = tmp_path / "config"; cfg_dir.mkdir() + secrets_store.set_room_password(_ROOM_PUBKEY, "x", cfg_dir) + secrets_store.set_room_password(_ROOM_PUBKEY, "", cfg_dir) + assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None + + def test_get_missing_pubkey_none(self, tmp_path): + cfg_dir = tmp_path / "config"; cfg_dir.mkdir() + assert secrets_store.get_room_password("", cfg_dir) is None + + +# =========================================================================== +# 5. channels.parse_meshcore_room() +# =========================================================================== + +class TestParseMeshcoreRoom: + def test_room_prefix_extracts_pubkey(self): + assert ch.parse_meshcore_room("room:" + _ROOM_PUBKEY) == _ROOM_PUBKEY + + def test_bare_channel_name_is_none(self): + assert ch.parse_meshcore_room("AIDA") is None + + def test_empty_room_pubkey_is_none(self): + assert ch.parse_meshcore_room("room:") is None + assert ch.parse_meshcore_room("room: ") is None + + def test_none_and_empty_are_none(self): + assert ch.parse_meshcore_room(None) is None + assert ch.parse_meshcore_room("") is None + + +# =========================================================================== +# 6. MeshCoreBroadcastChannel routing (room vs channel) +# =========================================================================== + +class _RecConnector: + """Records send_message_async kwargs; looks like a single meshcore transport.""" + transport_name = "meshcore" + max_chars = 200 + + def __init__(self): + self.calls = [] + + async def send_message_async(self, text=None, destination=None, channel=0, + transport=None, meshcore_channel=None, + meshcore_room=None, meshcore_room_password=None): + self.calls.append({ + "text": text, "destination": destination, + "meshcore_channel": meshcore_channel, + "meshcore_room": meshcore_room, + "meshcore_room_password": meshcore_room_password, + "transport": transport, + }) + return True + + +def _run_sync(coro): + """Run a coroutine on a fresh loop, restoring the prior event-loop state. + + Creating+closing a loop without restoring leaves a CLOSED loop as this + thread's default, which breaks sibling test modules that call + asyncio.get_event_loop() (e.g. the advert-scheduler tests). We snapshot the + current loop and put it back afterwards so there is no cross-module leak. + """ + try: + prev = asyncio.get_event_loop() + except RuntimeError: + prev = None + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + asyncio.set_event_loop(prev) + + +def _payload(msg="fire near you"): + p = MagicMock() + p.message = msg + p.chunk_index = 0 # pre-chunked -> single send, no renderer indirection + return p + + +class TestMeshCoreBroadcastChannelRouting: + def test_room_cell_routes_to_room_send(self, monkeypatch): + """A room: cell -> send_message_async(meshcore_room=pubkey), + never a channel broadcast.""" + monkeypatch.setattr( + "meshai.secrets_store.get_room_password", lambda pk: None + ) + conn = _RecConnector() + chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="room:" + _ROOM_PUBKEY) + ok = _run_sync(chan.deliver(_payload(), MagicMock())) + assert ok is True + assert len(conn.calls) == 1 + call = conn.calls[0] + assert call["meshcore_room"] == _ROOM_PUBKEY + assert call["meshcore_channel"] is None, "must NOT route as a channel broadcast" + + def test_room_cell_passes_configured_password(self, monkeypatch): + monkeypatch.setattr( + "meshai.secrets_store.get_room_password", lambda pk: "hunter2" + ) + conn = _RecConnector() + chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="room:" + _ROOM_PUBKEY) + _run_sync(chan.deliver(_payload(), MagicMock())) + assert conn.calls[0]["meshcore_room_password"] == "hunter2" + + def test_channel_cell_still_broadcasts(self, monkeypatch): + """Regression: a bare channel name -> meshcore_channel broadcast, no room.""" + conn = _RecConnector() + chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="AIDA") + ok = _run_sync(chan.deliver(_payload(), MagicMock())) + assert ok is True + call = conn.calls[0] + assert call["meshcore_channel"] == "AIDA" + assert call["meshcore_room"] is None, "channel cell must NOT trigger a room send" + + def test_no_channel_configured_is_noop(self): + """No cell set -> nothing sent (unchanged behavior).""" + conn = _RecConnector() + chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel=None) + ok = _run_sync(chan.deliver(_payload(), MagicMock())) + assert ok is False + assert conn.calls == []