diff --git a/config/default.yaml b/config/default.yaml index b8f7bad9..dd91fedc 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -11,6 +11,11 @@ radio: sync_word: 0x2B preamble_length: 16 tx_power_dbm: 22 + # HAL GPS/PPS (RAK Pi HAT u-blox → concentrator PPS pin). Off by default. + # gps_pps_enabled: false + # gps_pps_tty_path: "/dev/ttyAMA0" + # gps_family: "ubx7" + # gps_pps_target_baud: 0 # 0 = HAL default (9600) meshtastic: default_key_b64: "AQ==" @@ -19,6 +24,9 @@ meshtastic: # MyChannel: "base64encodedPSK==" # SecretChat: "anotherBase64PSK==" channel_keys: {} + # Remote ADMIN config read (PR 15) — PSK for the shared admin channel on target nodes. + # admin_key_b64: "base64PSK==" + # admin_channel_name: "admin" meshcore: default_key_b64: "" @@ -50,7 +58,7 @@ device: longitude: 0.0 altitude: 25 hardware_description: "RAK2287 + Raspberry Pi 4" - firmware_version: "0.7.6" + firmware_version: "0.7.5.1" relay: enabled: false @@ -60,6 +68,18 @@ relay: burst_size: 5 min_relay_rssi: -110.0 max_relay_rssi: -50.0 + # blocklist: [] # 8-char hex node IDs — relay only, feed unchanged + # priority_list: [] # bypass burst gate under congestion + # dedup_ttl_seconds: 300 + # channel_throttle_percent: # per-channel relay duty budget (est. ToA) + # "0": 100 # omit = 100% all channels; EU capped at 1% + # storm_guard: # memory-only quarantine (PR 12) + # enabled: false + # window_seconds: 60 + # identical_packet_threshold: 5 + # rate_threshold_per_minute: 30 + # quarantine_duration_seconds: 300 + # notify_dashboard: true upstream: enabled: true @@ -80,13 +100,6 @@ transmit: nodeinfo: interval_minutes: 180 # 5..1440 min, default 180 (3 hr); 0 = disabled startup_delay_seconds: 60 # delay before first broadcast on boot - position: - interval_minutes: 15 - startup_delay_seconds: 180 - # LoRa mesh POSITION packets (Meshtastic app map). Meshradar fleet pin - # always uses device.latitude/longitude above, not live GPS. - coordinate_source: "static" # static | live (live needs gpsd/uart source) - location_precision: "approximate" # exact | approximate | none (live only) web_auth: # First-run state: hashes are empty -> dashboard forces /setup. @@ -124,18 +137,29 @@ mqtt: homeassistant_discovery: false location: - # Where live GPS fixes come from (skyplot + optional mesh POSITION). - # Registered coordinates for Meshradar live in device.latitude/longitude - # and are not overwritten by gpsd. + # Where the Meshpoint's reported lat/lon/alt comes from. # static : use device.latitude/longitude/altitude (default) # gpsd : poll a local or remote gpsd daemon for live fixes - # uart : reserved (RAK Pi HAT GPS, not yet wired) + # uart : on-board RAK Pi HAT GPS via /dev/ttyAMA0 (NMEA GGA) source: "static" # gpsd connection. Defaults match the well-known local socket; only # change when running gpsd on a peer machine on the LAN. gpsd_host: "127.0.0.1" gpsd_port: 2947 + uart_path: "/dev/ttyAMA0" + uart_baud: 9600 # How often the coordinator wakes to read the active source. update_interval_seconds: 5 # Minimum acceptable fix mode: 0=any (incl. no-fix), 1=2D, 2=3D. min_fix_quality: 1 + +# signal_health: +# green_rssi_floor: -100 # badge green when 1h avg RSSI is above this +# yellow_rssi_floor: -115 # badge yellow above this, red below +# min_packets_per_hour: 5 # hide badge when fewer packets in the last hour + +# automation: +# enabled: false +# # Long random secret for LAN scripts (Home Assistant, Node-RED). Generate with: +# # openssl rand -hex 32 +# token: "" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f12b077d..9745c620 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -160,6 +160,20 @@ The setup wizard configures sources automatically. To add or remove a MeshCore c When running both Meshtastic concentrator capture and a MeshCore USB companion, pin `meshcore_usb.serial_port` explicitly. Auto-detect can grab the wrong device when multiple Espressif boards are attached. +### Remote node config read (ADMIN, read-only) + +**Node drawer → Remote Configuration** sends a single encrypted ADMIN `get_config_request` (or `get_owner_request`) per click. Responses display read-only in the drawer. Requires native TX (`transmit.enabled: true`) and a shared admin channel PSK on target nodes. + +```yaml +meshtastic: + admin_key_b64: "base64PSK==" # PSK for the shared "admin" channel + admin_channel_name: "admin" # optional; default "admin" +``` + +- Admin-only API; audit action `admin.config_request` (target node + section only — no PSK in logs). +- 30 s response timeout with retry UI; 30 s debounce between requests per node. +- **Read-only** — no config writes in this release. + --- ## Location (GPS) source diff --git a/frontend/css/node_drawer.css b/frontend/css/node_drawer.css index 06786d78..f04dbc53 100644 --- a/frontend/css/node_drawer.css +++ b/frontend/css/node_drawer.css @@ -322,6 +322,65 @@ pointer-events: auto; } +/* ---- Remote ADMIN config (node drawer) ---- */ + +.nd-remote-config__toolbar { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.nd-remote-config__select { + flex: 1; + min-width: 0; + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-primary); + font-size: 0.85rem; +} + +.nd-remote-config__btn { + flex-shrink: 0; + font-size: 0.8rem; + padding: 0.35rem 0.6rem; +} + +.nd-remote-config__status { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0.25rem 0 0.5rem; +} + +.nd-remote-config__hint { + font-size: 0.8rem; + color: var(--text-muted); + margin: 0; +} + +.nd-remote-config__hint code { + font-size: 0.75rem; +} + +.nd-remote-config__result { + max-height: 220px; + overflow-y: auto; + border-top: 1px solid var(--border); + padding-top: 0.35rem; +} + +.nd-remote-config__retry { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; +} + @media (max-width: 480px) { .nd-drawer { width: 100%; diff --git a/frontend/js/node_drawer.js b/frontend/js/node_drawer.js index b570e3b3..f1b326b8 100644 --- a/frontend/js/node_drawer.js +++ b/frontend/js/node_drawer.js @@ -11,6 +11,10 @@ class NodeDrawer { this._sections = {}; this._metricsChart = null; this._metricsHours = 24; + this._adminConfigAvailable = false; + this._adminDebounceSeconds = 30; + this._adminPollTimer = null; + this._adminConfigDebounceUntil = 0; if (window.MeshpointNodeFavorites) { window.MeshpointNodeFavorites.onChange(() => this._refreshFavoriteButton()); @@ -23,10 +27,15 @@ class NodeDrawer { this._renderSkeleton(node); this._drawer.classList.add('nd-drawer--open'); - const [detail, metrics] = await Promise.all([ + const [detail, metrics, adminStatus] = await Promise.all([ this._fetchDetail(node.node_id), this._fetchMetricsHistory(node.node_id, this._metricsHours), + this._fetchAdminConfigStatus(), ]); + if (adminStatus) { + this._adminConfigAvailable = !!adminStatus.available; + this._adminDebounceSeconds = adminStatus.debounce_seconds || 30; + } const merged = { ...node, ...detail }; merged._metricsHistory = metrics; @@ -38,6 +47,7 @@ class NodeDrawer { } close() { + this._stopAdminPoll(); if (this._metricsChart) { this._metricsChart.destroy(); this._metricsChart = null; @@ -110,6 +120,7 @@ class NodeDrawer { body.innerHTML = ''; body.appendChild(this._buildActions(n)); + body.appendChild(this._buildRemoteConfigSection(n)); body.appendChild(this._buildMetricsChartSection(n)); body.appendChild(this._buildInfoSection(n)); body.appendChild(this._buildSignalSection(n)); @@ -145,6 +156,227 @@ class NodeDrawer { return div; } + _buildRemoteConfigSection(n) { + const section = document.createElement('div'); + section.className = 'nd-section nd-section--remote-config'; + + const header = document.createElement('div'); + header.className = 'nd-section__header'; + header.innerHTML = `Remote Configuration + \u25BC`; + + const content = document.createElement('div'); + content.className = 'nd-section__content nd-remote-config'; + + if (!this._adminConfigAvailable) { + content.innerHTML = `

Set meshtastic.admin_key_b64 in local.yaml to enable ADMIN config read.

`; + } else { + const toolbar = document.createElement('div'); + toolbar.className = 'nd-remote-config__toolbar'; + + const select = document.createElement('select'); + select.className = 'nd-remote-config__select'; + select.setAttribute('aria-label', 'Config section'); + [ + ['device', 'Device'], + ['owner', 'Owner'], + ['lora', 'LoRa'], + ['position', 'Position'], + ].forEach(([value, label]) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + select.appendChild(opt); + }); + toolbar.appendChild(select); + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'nd-action-btn nd-remote-config__btn'; + btn.textContent = 'Request Config'; + btn.addEventListener('click', () => { + this._requestRemoteConfig(n.node_id, select.value, btn, statusEl, resultEl); + }); + toolbar.appendChild(btn); + content.appendChild(toolbar); + + const statusEl = document.createElement('p'); + statusEl.className = 'nd-remote-config__status'; + statusEl.textContent = 'No request yet.'; + content.appendChild(statusEl); + + const resultEl = document.createElement('div'); + resultEl.className = 'nd-remote-config__result'; + content.appendChild(resultEl); + + this._refreshRemoteConfigPanel(n.node_id, btn, statusEl, resultEl); + } + + header.addEventListener('click', () => { + const visible = content.style.display !== 'none'; + content.style.display = visible ? 'none' : ''; + header.querySelector('.nd-section__arrow').textContent = visible ? '\u25B6' : '\u25BC'; + }); + + section.appendChild(header); + section.appendChild(content); + return section; + } + + async _requestRemoteConfig(nodeId, section, btn, statusEl, resultEl) { + const now = Date.now(); + if (now < this._adminConfigDebounceUntil) { + const left = Math.ceil((this._adminConfigDebounceUntil - now) / 1000); + statusEl.textContent = `Wait ${left}s before another request.`; + return; + } + + btn.disabled = true; + statusEl.textContent = 'Sending ADMIN request…'; + resultEl.innerHTML = ''; + + try { + const res = await fetch( + `/api/admin/nodes/${encodeURIComponent(nodeId)}/config/request`, + { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section }), + }, + ); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + statusEl.textContent = body.detail || `Request failed (${res.status})`; + if (res.status === 429) { + this._adminConfigDebounceUntil = Date.now() + this._adminDebounceSeconds * 1000; + } + return; + } + this._adminConfigDebounceUntil = Date.now() + this._adminDebounceSeconds * 1000; + statusEl.textContent = 'Waiting for response (up to 30s)…'; + this._startAdminPoll(nodeId, btn, statusEl, resultEl); + } catch (err) { + statusEl.textContent = 'Network error requesting config.'; + } finally { + btn.disabled = Date.now() < this._adminConfigDebounceUntil; + } + } + + _startAdminPoll(nodeId, btn, statusEl, resultEl) { + this._stopAdminPoll(); + const started = Date.now(); + const poll = async () => { + const data = await this._fetchRemoteConfigStatus(nodeId); + if (!data) return; + + if (data.status === 'pending') { + const elapsed = Math.floor((Date.now() - started) / 1000); + statusEl.textContent = `Waiting for response… ${elapsed}s`; + return; + } + + this._stopAdminPoll(); + btn.disabled = Date.now() < this._adminConfigDebounceUntil; + + if (data.status === 'complete') { + statusEl.textContent = `Received ${data.section || ''} config.`; + resultEl.innerHTML = this._formatRemoteConfigResult(data.config); + } else if (data.status === 'timeout') { + statusEl.innerHTML = 'No response within 30s. ' + + ''; + const retry = statusEl.querySelector('.nd-remote-config__retry'); + if (retry) { + retry.addEventListener('click', () => { + const select = this._drawer.querySelector('.nd-remote-config__select'); + const section = select ? select.value : 'device'; + this._requestRemoteConfig(nodeId, section, btn, statusEl, resultEl); + }); + } + } else { + statusEl.textContent = data.error || `Status: ${data.status}`; + } + }; + this._adminPollTimer = setInterval(poll, 2000); + poll(); + } + + _stopAdminPoll() { + if (this._adminPollTimer) { + clearInterval(this._adminPollTimer); + this._adminPollTimer = null; + } + } + + async _refreshRemoteConfigPanel(nodeId, btn, statusEl, resultEl) { + const data = await this._fetchRemoteConfigStatus(nodeId); + if (!data || data.status === 'idle') return; + if (data.status === 'pending') { + statusEl.textContent = 'Request in progress…'; + this._startAdminPoll(nodeId, btn, statusEl, resultEl); + return; + } + if (data.status === 'complete' && data.config) { + statusEl.textContent = `Last read: ${data.section || ''}`; + resultEl.innerHTML = this._formatRemoteConfigResult(data.config); + } else if (data.error) { + statusEl.textContent = data.error; + } + } + + _formatRemoteConfigResult(config) { + if (!config) return ''; + const rows = this._flattenConfigRows(config, ''); + if (rows.length === 0) { + return '

Empty response.

'; + } + return rows.map(([k, v]) => ( + `
${this._esc(k)}` + + `${this._esc(String(v))}
` + )).join(''); + } + + _flattenConfigRows(obj, prefix) { + const rows = []; + if (obj == null || typeof obj !== 'object') { + return rows; + } + Object.entries(obj).forEach(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (value != null && typeof value === 'object' && !Array.isArray(value)) { + rows.push(...this._flattenConfigRows(value, path)); + } else if (Array.isArray(value)) { + rows.push([path, value.join(', ')]); + } else if (value !== '' && value != null) { + rows.push([path, value]); + } + }); + return rows; + } + + async _fetchAdminConfigStatus() { + try { + const res = await fetch('/api/admin/remote-config/status', { credentials: 'same-origin' }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } + } + + async _fetchRemoteConfigStatus(nodeId) { + try { + const res = await fetch( + `/api/admin/nodes/${encodeURIComponent(nodeId)}/config`, + { credentials: 'same-origin' }, + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } + } + _buildInfoSection(n) { const rows = []; if (n.hardware_model) rows.push(['Hardware', n.hardware_model]); diff --git a/src/admin/__init__.py b/src/admin/__init__.py new file mode 100644 index 00000000..68372f31 --- /dev/null +++ b/src/admin/__init__.py @@ -0,0 +1 @@ +"""Remote Meshtastic ADMIN config read (PR 15).""" diff --git a/src/admin/config_decode.py b/src/admin/config_decode.py new file mode 100644 index 00000000..3c333cdb --- /dev/null +++ b/src/admin/config_decode.py @@ -0,0 +1,29 @@ +"""Decode Meshtastic ADMIN responses into JSON-safe, redacted dicts.""" +from __future__ import annotations + +import re +from typing import Any + +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message + +_SENSITIVE_RE = re.compile( + r"(psk|private_key|public_key|admin_key|session_passkey|passkey|key)$", + re.IGNORECASE, +) + + +def message_to_redacted_dict(msg: Message) -> dict[str, Any]: + """Convert a protobuf message to a dict with secrets redacted.""" + raw = MessageToDict(msg, preserving_proto_field_name=True) + return _redact_value(raw) + + +def _redact_value(value: Any, key: str | None = None) -> Any: + if key and _SENSITIVE_RE.search(key): + return "***" + if isinstance(value, dict): + return {k: _redact_value(v, k) for k, v in value.items()} + if isinstance(value, list): + return [_redact_value(item) for item in value] + return value diff --git a/src/admin/pending_store.py b/src/admin/pending_store.py new file mode 100644 index 00000000..98a696de --- /dev/null +++ b/src/admin/pending_store.py @@ -0,0 +1,107 @@ +"""In-memory pending remote config read requests (one slot per node).""" +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Literal + +RequestStatus = Literal["idle", "pending", "complete", "timeout", "error"] + +REQUEST_TIMEOUT_SECONDS = 30.0 +DEBOUNCE_SECONDS = 30.0 + + +@dataclass +class ConfigReadState: + node_id: str + request_id: str = "" + section: str = "device" + status: RequestStatus = "idle" + requested_at: float = 0.0 + completed_at: float = 0.0 + packet_id: str = "" + config: dict[str, Any] | None = None + error: str = "" + expected_response: str = "get_config_response" + + +class PendingConfigStore: + """Tracks pending/completed config reads keyed by normalized node ID.""" + + def __init__(self) -> None: + self._by_node: dict[str, ConfigReadState] = {} + + def get(self, node_id: str) -> ConfigReadState | None: + state = self._by_node.get(node_id) + if state is None: + return None + self._maybe_timeout(state) + return state + + def can_request(self, node_id: str) -> tuple[bool, str]: + state = self._by_node.get(node_id) + if state is None: + return True, "" + self._maybe_timeout(state) + if state.status == "pending": + elapsed = time.time() - state.requested_at + if elapsed < DEBOUNCE_SECONDS: + remaining = int(DEBOUNCE_SECONDS - elapsed) + return False, f"Request in progress; retry in {remaining}s" + if state.status in ("pending", "complete", "timeout", "error"): + elapsed = time.time() - state.requested_at + if elapsed < DEBOUNCE_SECONDS: + remaining = int(DEBOUNCE_SECONDS - elapsed) + return False, f"Wait {remaining}s before another request" + return True, "" + + def begin( + self, + node_id: str, + *, + section: str, + packet_id: str, + expected_response: str, + ) -> ConfigReadState: + state = ConfigReadState( + node_id=node_id, + request_id=uuid.uuid4().hex[:12], + section=section, + status="pending", + requested_at=time.time(), + packet_id=packet_id, + expected_response=expected_response, + config=None, + error="", + ) + self._by_node[node_id] = state + return state + + def complete( + self, + node_id: str, + config: dict[str, Any], + ) -> None: + state = self._by_node.get(node_id) + if state is None or state.status != "pending": + return + state.status = "complete" + state.completed_at = time.time() + state.config = config + + def fail(self, node_id: str, error: str) -> None: + state = self._by_node.get(node_id) + if state is None: + return + state.status = "error" + state.completed_at = time.time() + state.error = error + + def _maybe_timeout(self, state: ConfigReadState) -> None: + if state.status != "pending": + return + if time.time() - state.requested_at >= REQUEST_TIMEOUT_SECONDS: + state.status = "timeout" + state.completed_at = time.time() + state.error = "No response within 30 seconds" diff --git a/src/admin/reader.py b/src/admin/reader.py new file mode 100644 index 00000000..ced6cbdd --- /dev/null +++ b/src/admin/reader.py @@ -0,0 +1,284 @@ +"""Build ADMIN get-config requests and correlate inbound responses.""" +from __future__ import annotations + +import base64 +import logging +import struct +from typing import Any + +from meshtastic.protobuf import admin_pb2, mesh_pb2 + +from src.admin.config_decode import message_to_redacted_dict +from src.admin.pending_store import PendingConfigStore +from src.decode.crypto_service import CryptoService +from src.models.packet import Packet +from src.relay.node_id import normalize_node_id, validate_node_ids +from src.transmit.tx_service import SendResult, TxService + +logger = logging.getLogger(__name__) + +PORTNUM_ADMIN = 6 +MESHTASTIC_HEADER_SIZE = 16 + +_SECTION_CONFIG_TYPE: dict[str, int] = { + "device": admin_pb2.AdminMessage.DEVICE_CONFIG, + "position": admin_pb2.AdminMessage.POSITION_CONFIG, + "power": admin_pb2.AdminMessage.POWER_CONFIG, + "network": admin_pb2.AdminMessage.NETWORK_CONFIG, + "display": admin_pb2.AdminMessage.DISPLAY_CONFIG, + "lora": admin_pb2.AdminMessage.LORA_CONFIG, + "bluetooth": admin_pb2.AdminMessage.BLUETOOTH_CONFIG, + "security": admin_pb2.AdminMessage.SECURITY_CONFIG, +} + + +class AdminConfigReader: + """Send ADMIN read requests and capture responses from the RX pipeline.""" + + def __init__( + self, + *, + tx_service: TxService | None, + crypto: CryptoService | None, + admin_key_b64: str = "", + admin_channel_name: str = "admin", + local_node_id: int = 0, + ) -> None: + self._tx = tx_service + self._crypto = crypto + self._admin_key_b64 = (admin_key_b64 or "").strip() + self._admin_channel_name = (admin_channel_name or "admin").strip() or "admin" + self._local_node_id = local_node_id + self._store = PendingConfigStore() + self._admin_key: bytes | None = None + self._refresh_admin_key() + + @property + def available(self) -> bool: + return bool(self._admin_key_b64 and self._admin_key) + + @property + def store(self) -> PendingConfigStore: + return self._store + + def update_config( + self, + *, + admin_key_b64: str = "", + admin_channel_name: str = "admin", + local_node_id: int = 0, + ) -> None: + self._admin_key_b64 = (admin_key_b64 or "").strip() + self._admin_channel_name = (admin_channel_name or "admin").strip() or "admin" + self._local_node_id = local_node_id + self._refresh_admin_key() + + def _refresh_admin_key(self) -> None: + if not self._admin_key_b64: + self._admin_key = None + return + try: + raw = base64.b64decode(self._admin_key_b64) + self._admin_key = CryptoService._expand_key(raw) + except Exception: + logger.warning("Invalid meshtastic.admin_key_b64", exc_info=True) + self._admin_key = None + + async def request_config( + self, + node_id: str, + section: str = "device", + ) -> dict[str, Any]: + if not self.available: + raise AdminConfigError( + "Remote config read unavailable — set meshtastic.admin_key_b64 in local.yaml", + status_code=503, + ) + if self._tx is None or not self._tx.meshtastic_enabled: + raise AdminConfigError( + "Meshtastic TX not available (transmit.enabled required)", + status_code=503, + ) + + normalized = validate_node_ids([node_id])[0] + section_key = (section or "device").strip().lower() + if section_key not in _SECTION_CONFIG_TYPE and section_key != "owner": + raise AdminConfigError( + f"Unknown config section {section!r}", + status_code=400, + ) + + ok, reason = self._store.can_request(normalized) + if not ok: + raise AdminConfigError(reason, status_code=429) + + dest_int = int(normalized, 16) + if dest_int in (0, 0xFFFFFFFF): + raise AdminConfigError( + "Cannot request config from broadcast node", + status_code=400, + ) + + admin_msg = self._build_admin_message(section_key) + expected = ( + "get_owner_response" + if section_key == "owner" + else "get_config_response" + ) + result = await self._tx.send_admin_message( + admin_payload=admin_msg.SerializeToString(), + destination=dest_int, + admin_key=self._admin_key, + admin_channel_name=self._admin_channel_name, + want_ack=True, + ) + if not result.success: + raise AdminConfigError( + result.error or "ADMIN TX failed", + status_code=502, + ) + + state = self._store.begin( + normalized, + section=section_key, + packet_id=result.packet_id, + expected_response=expected, + ) + return self._state_payload(state) + + def get_status(self, node_id: str) -> dict[str, Any]: + normalized = normalize_node_id(node_id) + state = self._store.get(normalized) + if state is None: + return { + "node_id": normalized, + "status": "idle", + "section": None, + "config": None, + "error": "", + } + return self._state_payload(state) + + def try_consume_packet(self, packet: Packet) -> None: + if not self.available or self._crypto is None: + return + if packet.protocol.value != "meshtastic": + return + + source = normalize_node_id(packet.source_id or "") + state = self._store.get(source) + if state is None or state.status != "pending": + return + + raw = packet.raw_radio_packet + if not raw or len(raw) < MESHTASTIC_HEADER_SIZE: + return + + header = self._parse_header(raw[:MESHTASTIC_HEADER_SIZE]) + if header is None: + return + + if self._local_node_id and header["dest_id"] not in ( + self._local_node_id, + 0xFFFFFFFF, + ): + return + + encrypted = raw[MESHTASTIC_HEADER_SIZE:] + decrypted = self._crypto.decrypt_meshtastic( + encrypted, + header["packet_id"], + header["source_id"], + key=self._admin_key, + ) + if decrypted is None: + return + + data = mesh_pb2.Data() + try: + data.ParseFromString(decrypted) + except Exception: + return + if data.portnum != PORTNUM_ADMIN: + return + + admin_msg = admin_pb2.AdminMessage() + try: + admin_msg.ParseFromString(data.payload) + except Exception: + return + + payload = self._extract_response(admin_msg, state.expected_response) + if payload is None: + return + + self._store.complete(source, payload) + logger.info( + "ADMIN config response from %s section=%s request_id=%s", + source, + state.section, + state.request_id, + ) + + @staticmethod + def _build_admin_message(section: str) -> admin_pb2.AdminMessage: + msg = admin_pb2.AdminMessage() + if section == "owner": + msg.get_owner_request = True + else: + msg.get_config_request = _SECTION_CONFIG_TYPE[section] + return msg + + @staticmethod + def _extract_response( + admin_msg: admin_pb2.AdminMessage, + expected: str, + ) -> dict[str, Any] | None: + if expected == "get_owner_response": + if not admin_msg.HasField("get_owner_response"): + return None + return { + "section": "owner", + "owner": message_to_redacted_dict(admin_msg.get_owner_response), + } + if not admin_msg.HasField("get_config_response"): + return None + cfg_dict = message_to_redacted_dict(admin_msg.get_config_response) + populated = { + k: v for k, v in cfg_dict.items() if v is not None and v != {} + } + return {"section": "config", "config": populated} + + @staticmethod + def _parse_header(header_bytes: bytes) -> dict[str, int] | None: + try: + dest_id, source_id, packet_id = struct.unpack_from( + " dict[str, Any]: + return { + "node_id": state.node_id, + "request_id": state.request_id, + "status": state.status, + "section": state.section, + "packet_id": state.packet_id, + "requested_at": state.requested_at, + "completed_at": state.completed_at or None, + "config": state.config, + "error": state.error, + } + + +class AdminConfigError(Exception): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code diff --git a/src/api/routes/admin_routes.py b/src/api/routes/admin_routes.py new file mode 100644 index 00000000..e52d2ecc --- /dev/null +++ b/src/api/routes/admin_routes.py @@ -0,0 +1,102 @@ +"""Remote Meshtastic ADMIN config read routes (PR 15).""" +from __future__ import annotations + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from src.admin.pending_store import DEBOUNCE_SECONDS, REQUEST_TIMEOUT_SECONDS +from src.admin.reader import AdminConfigError, AdminConfigReader +from src.api.audit import AuditLogWriter +from src.api.audit.dependencies import get_audit_writer +from src.api.auth.dependencies import require_admin +from src.api.auth.jwt_session import SessionClaims + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +_reader: AdminConfigReader | None = None + + +def init_routes(*, reader: AdminConfigReader | None) -> None: + global _reader + _reader = reader + + +def reset_routes() -> None: + global _reader + _reader = None + + +class ConfigRequestBody(BaseModel): + section: Literal[ + "device", + "owner", + "lora", + "position", + "power", + "network", + "display", + "bluetooth", + "security", + ] = Field(default="device") + + +@router.get("/remote-config/status") +async def remote_config_status(): + """Whether remote ADMIN config read is configured (no secrets).""" + if _reader is None: + return { + "available": False, + "debounce_seconds": DEBOUNCE_SECONDS, + "timeout_seconds": REQUEST_TIMEOUT_SECONDS, + } + return { + "available": _reader.available, + "debounce_seconds": DEBOUNCE_SECONDS, + "timeout_seconds": REQUEST_TIMEOUT_SECONDS, + } + + +@router.get("/nodes/{node_id}/config") +async def get_remote_config(node_id: str): + """Poll status/result for the latest config read on a node.""" + if _reader is None: + raise HTTPException(503, "Remote config reader not initialized") + return _reader.get_status(node_id) + + +@router.post("/nodes/{node_id}/config/request") +async def request_remote_config( + node_id: str, + body: ConfigRequestBody | None = None, + section: str | None = Query(default=None), + claims: SessionClaims = Depends(require_admin), + audit: AuditLogWriter = Depends(get_audit_writer), +): + """Send one ADMIN get-config request to a remote node (read-only).""" + if _reader is None: + raise HTTPException(503, "Remote config reader not initialized") + + chosen = (body.section if body else None) or section or "device" + params = { + "target_node_id": node_id.strip().lower().lstrip("!"), + "section": chosen, + } + + with audit.timed_action( + user=claims.subject, + action="admin.config_request", + params=params, + ) as ctx: + try: + result = await _reader.request_config(node_id, section=chosen) + ctx.params["packet_id"] = result.get("packet_id", "") + ctx.params["request_id"] = result.get("request_id", "") + ctx.params["status"] = result.get("status", "pending") + return result + except AdminConfigError as exc: + raise HTTPException(exc.status_code, str(exc)) from exc diff --git a/src/api/routes/config_enrichment.py b/src/api/routes/config_enrichment.py index 5f0ae3a6..6097ebb3 100644 --- a/src/api/routes/config_enrichment.py +++ b/src/api/routes/config_enrichment.py @@ -52,24 +52,51 @@ def enrich_config_payload(cfg: AppConfig, base: dict) -> dict: "burst_size": relay.burst_size, "min_relay_rssi": relay.min_relay_rssi, "max_relay_rssi": relay.max_relay_rssi, + "blocklist": list(relay.blocklist or []), + "priority_list": list(relay.priority_list or []), + "dedup_ttl_seconds": relay.dedup_ttl_seconds, + "channel_throttle_percent": dict(relay.channel_throttle_percent or {}), + "storm_guard": { + "enabled": relay.storm_guard.enabled, + "window_seconds": relay.storm_guard.window_seconds, + "identical_packet_threshold": relay.storm_guard.identical_packet_threshold, + "rate_threshold_per_minute": relay.storm_guard.rate_threshold_per_minute, + "quarantine_duration_seconds": relay.storm_guard.quarantine_duration_seconds, + "notify_dashboard": relay.storm_guard.notify_dashboard, + }, } base["radio_advanced"] = { "spectral_scan_interval_seconds": radio.spectral_scan_interval_seconds, "sx1261_spi_path": radio.sx1261_spi_path or "", + "carrier_type": radio.carrier_type or "", + "gps_pps_enabled": radio.gps_pps_enabled, + "gps_pps_tty_path": radio.gps_pps_tty_path, + "gps_family": radio.gps_family, + "gps_pps_target_baud": radio.gps_pps_target_baud, } base["location"] = { "source": location.source, "gpsd_host": location.gpsd_host, "gpsd_port": location.gpsd_port, + "uart_path": location.uart_path, + "uart_baud": location.uart_baud, "update_interval_seconds": location.update_interval_seconds, "min_fix_quality": location.min_fix_quality, } - pos = cfg.transmit.position - if "transmit" in base: - base["transmit"]["position"] = { - "interval_minutes": pos.interval_minutes, - "startup_delay_seconds": pos.startup_delay_seconds, - "coordinate_source": pos.coordinate_source, - "location_precision": pos.location_precision, - } + sh = cfg.signal_health + base["signal_health"] = { + "green_rssi_floor": sh.green_rssi_floor, + "yellow_rssi_floor": sh.yellow_rssi_floor, + "min_packets_per_hour": sh.min_packets_per_hour, + } + auto = cfg.automation + base["automation"] = { + "enabled": auto.enabled, + "token_set": bool((auto.token or "").strip()), + } + mt = cfg.meshtastic + base["meshtastic_admin"] = { + "config_read_available": bool((mt.admin_key_b64 or "").strip()), + "channel_name": (mt.admin_channel_name or "admin").strip() or "admin", + } return base diff --git a/src/api/server.py b/src/api/server.py index 3bd4fb06..6bcdf598 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -27,6 +27,7 @@ build_restart_service_action, build_wipe_phantoms_action, ) +from src.admin.reader import AdminConfigReader from src.api.meshcore_contacts import ( log_meshcore_contact_peers, schedule_startup_meshcore_contact_sync, @@ -34,6 +35,7 @@ sync_meshcore_contacts_to_nodes, ) from src.api.routes import ( + admin_routes, analytics, auth_config_routes, auth_routes, @@ -279,6 +281,7 @@ async def lifespan(app: FastAPI): app.include_router(system_config_routes.router, dependencies=protected) app.include_router(meshcore_config_routes.router, dependencies=protected) app.include_router(config_routes.router, dependencies=protected) + app.include_router(admin_routes.router, dependencies=protected) app.include_router(stats_routes.router, dependencies=protected) @app.websocket("/ws") @@ -1268,6 +1271,26 @@ def _init_routes( system_config_routes.init_routes(config=config) meshcore_config_routes.init_routes(config=config, tx_service=tx_service) + local_node_id = 0 + if tx_service is not None: + local_node_id = tx_service.source_node_id + elif config.transmit.node_id: + local_node_id = config.transmit.node_id + + admin_reader = AdminConfigReader( + tx_service=tx_service, + crypto=crypto, + admin_key_b64=config.meshtastic.admin_key_b64, + admin_channel_name=config.meshtastic.admin_channel_name, + local_node_id=local_node_id, + ) + admin_routes.init_routes(reader=admin_reader) + + def _on_admin_config_packet(packet: Packet) -> None: + admin_reader.try_consume_packet(packet) + + coord.on_packet(_on_admin_config_packet) + def _init_dangerous_registry(coord: PipelineCoordinator) -> None: """Compose the Settings → Dangerous registry now that the pipeline is live. diff --git a/src/config.py b/src/config.py index 4622e2b4..2e8f2dbe 100644 --- a/src/config.py +++ b/src/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import dataclasses -import logging import os import sys from dataclasses import dataclass, field @@ -12,8 +11,6 @@ from src.version import __version__ -logger = logging.getLogger(__name__) - # Band-start frequencies (MHz) for the Meshtastic slot formula # freq = freqStart + BW/2000 + (slot-1) * BW/1000 @@ -60,14 +57,26 @@ class RadioConfig: # (default; spectral scan stays unavailable, packet-derived # noise floor remains in use). # - # On RAK2287 / RAK5146 / SenseCap M1 this is typically - # ``/dev/spidev0.1`` (separate from the SX1302's - # ``/dev/spidev0.0``). Some carriers daisy-chain the SX1261 - # behind the SX1302's SPI router and want this set to the same - # path as the SX1302 SPI device. Wrong path = HAL refuses to - # ``lgw_start`` after our config attempt, so we ship empty by - # default and ask interested users to opt in explicitly. + # On RAK2287 / RAK5146 / SenseCap M1 the SX1261 is behind the + # concentrator's internal SPI router, not on a Pi chip-select — + # leave empty. Only the Semtech reference kit and custom carriers + # with a dedicated SX1261 CE line need a path (e.g. + # ``/dev/spidev0.1``). Wrong path can brick ``lgw_start`` on + # boards without Pi-visible SX1261; ``carrier_type`` guard clears + # mistaken values on RAK/SenseCap. sx1261_spi_path: str = "" + # Carrier board signature from setup wizard I2C probe (``rak``, + # ``sensecap_m1``, or empty). Used to block Pi-visible SX1261 SPI + # paths that brick ``lgw_start()`` on RAK/SenseCap concentrators. + carrier_type: str = "" + # HAL GPS/PPS: align concentrator packet timestamps with GPS time via + # libloragw ``lgw_gps_*`` + ``sx1302_gps_enable``. Requires a HAL build + # that includes loragw_gps.c. Exclusive with ``location.source: uart`` on + # the same TTY (only one process may open the GPS serial port). + gps_pps_enabled: bool = False + gps_pps_tty_path: str = "/dev/ttyAMA0" + gps_family: str = "ubx7" + gps_pps_target_baud: int = 0 @dataclass @@ -75,6 +84,9 @@ class MeshtasticConfig: default_key_b64: str = "AQ==" primary_channel_name: str = "LongFast" channel_keys: dict[str, str] = field(default_factory=dict) + # PSK for the shared "admin" channel — required for remote ADMIN config read. + admin_key_b64: str = "" + admin_channel_name: str = "admin" @dataclass @@ -114,6 +126,45 @@ class StorageConfig: cleanup_interval_seconds: int = 3600 +@dataclass +class StrayFramesConfig: + """Undecodable RF frame logger (PR 08).""" + + enabled: bool = True + max_retained: int = 10_000 + retention_hours: float = 168.0 + + +@dataclass +class MetricsConfig: + """Prometheus-compatible /metrics scrape endpoint (PR 09).""" + + enabled: bool = False + require_auth: bool = True + + +@dataclass +class WebhookRuleConfig: + """One outbound HTTP rule for a mesh event (PR 10).""" + + name: str = "" + url: str = "" + event: str = "" + enabled: bool = True + cooldown_seconds: float = 300.0 + keyword: Optional[str] = None + battery_threshold_percent: float = 20.0 + duty_threshold_percent: float = 80.0 + + +@dataclass +class WebhookConfig: + """Event-driven outbound webhooks (PR 10).""" + + enabled: bool = False + rules: list[WebhookRuleConfig] = field(default_factory=list) + + @dataclass class DashboardConfig: host: str = "0.0.0.0" # nosec B104 -- intentional for local device dashboard @@ -141,6 +192,18 @@ class DeviceConfig: firmware_version: str = __version__ +@dataclass +class StormGuardConfig: + """Memory-only storm/replay quarantine (PR 12).""" + + enabled: bool = False + window_seconds: int = 60 + identical_packet_threshold: int = 5 + rate_threshold_per_minute: int = 30 + quarantine_duration_seconds: int = 300 + notify_dashboard: bool = True + + @dataclass class RelayConfig: enabled: bool = False @@ -150,29 +213,11 @@ class RelayConfig: burst_size: int = 5 min_relay_rssi: float = -110.0 max_relay_rssi: float = -50.0 - - -@dataclass -class TelemetryConfig: - """Periodic device_metrics telemetry broadcast settings.""" - - interval_minutes: int = 30 - startup_delay_seconds: int = 120 - - -@dataclass -class PositionConfig: - """Periodic POSITION broadcast settings.""" - - interval_minutes: int = 15 - startup_delay_seconds: int = 180 - # Coordinates sent on the public LoRa mesh (Meshtastic POSITION packets). - # ``static`` uses ``device.{latitude,longitude,altitude}`` (wizard pin). - # ``live`` reads the active ``LocationSource`` (gpsd/uart) when a fix exists. - coordinate_source: str = "static" - # Privacy when ``coordinate_source`` is ``live``: exact, approximate - # (~1.1 km rounding), or none (skip position on mesh). Ignored for static. - location_precision: str = "approximate" + blocklist: list[str] = field(default_factory=list) + priority_list: list[str] = field(default_factory=list) + dedup_ttl_seconds: int = 300 + channel_throttle_percent: dict[str, float] = field(default_factory=dict) + storm_guard: StormGuardConfig = field(default_factory=StormGuardConfig) @dataclass @@ -184,8 +229,6 @@ class MqttConfig: password: str = "large4cats" topic_root: str = "msh" region: str = "US" - tls_enabled: bool = False - tls_ca_cert: str = "" # Optional ``!xxxxxxxx`` override; blank uses MD5 hash of device name. gateway_id: Optional[str] = None publish_channels: list[str] = field(default_factory=lambda: ["LongFast", "MeshCore"]) @@ -230,8 +273,6 @@ class TransmitConfig: short_name: str = "MPNT" hop_limit: int = 3 nodeinfo: NodeInfoConfig = field(default_factory=NodeInfoConfig) - telemetry: TelemetryConfig = field(default_factory=TelemetryConfig) - position: PositionConfig = field(default_factory=PositionConfig) @dataclass @@ -241,15 +282,12 @@ class LocationConfig: ``source`` values: - ``"static"`` : use ``device.latitude/longitude/altitude`` from ``local.yaml``. Backward-compatible default. - - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon for - live fixes (skyplot, optional mesh POSITION). - Does not change ``device.{lat,lon,alt}`` (Meshradar - pin). Auto-installed by ``scripts/install.sh``. - - ``"uart"`` : reserved for direct on-board UART NMEA reading - (RAK Pi HAT GPS). Plumbing exists in - ``src.hal.gps_reader`` but is not wired into - the runtime yet; treated as ``static`` until - the source is implemented. + - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon and + overwrite ``device.{lat,lon,alt}`` when fixes + arrive. Auto-installed by ``scripts/install.sh``. + - ``"uart"`` : read NMEA GGA from an on-board UART GPS (RAK Pi + HAT on ``/dev/ttyAMA0``). Uses + ``src.hal.gps_reader.GpsReader``. ``gpsd_host`` / ``gpsd_port`` default to gpsd's well-known localhost socket. Override only when running gpsd on a peer @@ -269,6 +307,8 @@ class LocationConfig: source: str = "static" gpsd_host: str = "127.0.0.1" gpsd_port: int = 2947 + uart_path: str = "/dev/ttyAMA0" + uart_baud: int = 9600 update_interval_seconds: int = 5 min_fix_quality: int = 1 @@ -304,6 +344,23 @@ class WebAuthConfig: session_version: int = 1 +@dataclass +class SignalHealthConfig: + """Thresholds for node-card RSSI health badges and sparklines.""" + + green_rssi_floor: float = -100 + yellow_rssi_floor: float = -115 + min_packets_per_hour: int = 5 + + +@dataclass +class ApiAutomationConfig: + """LAN automation API for Home Assistant / Node-RED scripting.""" + + enabled: bool = False + token: str = "" + + @dataclass class AppConfig: radio: RadioConfig = field(default_factory=RadioConfig) @@ -319,6 +376,11 @@ class AppConfig: transmit: TransmitConfig = field(default_factory=TransmitConfig) web_auth: WebAuthConfig = field(default_factory=WebAuthConfig) location: LocationConfig = field(default_factory=LocationConfig) + signal_health: SignalHealthConfig = field(default_factory=SignalHealthConfig) + automation: ApiAutomationConfig = field(default_factory=ApiAutomationConfig) + stray_frames: StrayFramesConfig = field(default_factory=StrayFramesConfig) + metrics: MetricsConfig = field(default_factory=MetricsConfig) + webhooks: WebhookConfig = field(default_factory=WebhookConfig) def _resolve_radio_frequency(radio: "RadioConfig") -> None: @@ -356,25 +418,6 @@ def _merge_dataclass(instance, overrides: dict): setattr(instance, key, value) -def _collect_unknown_keys(instance, overrides: dict, prefix: str = "") -> list[str]: - """Return dotted paths of override keys with no matching dataclass field. - - Mirrors the descent rules in :func:`_merge_dataclass`: it only recurses - into a nested dataclass (e.g. ``transmit.nodeinfo``), so user-supplied - mapping fields such as ``meshtastic.channel_keys`` are treated as opaque - values rather than scanned for "unknown" keys. - """ - unknown: list[str] = [] - for key, value in overrides.items(): - if not hasattr(instance, key): - unknown.append(f"{prefix}{key}") - continue - current = getattr(instance, key) - if dataclasses.is_dataclass(current) and isinstance(value, dict): - unknown.extend(_collect_unknown_keys(current, value, f"{prefix}{key}.")) - return unknown - - def _apply_yaml(cfg: AppConfig, path: Path) -> None: """Merge a single YAML file into an existing AppConfig.""" if not path.exists(): @@ -383,10 +426,6 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None: with open(path, "r") as fh: raw = yaml.safe_load(fh) or {} - if not isinstance(raw, dict): - logger.warning("Ignoring %s: top-level YAML is not a mapping.", path) - return - section_map = { "radio": cfg.radio, "meshtastic": cfg.meshtastic, @@ -401,28 +440,16 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None: "transmit": cfg.transmit, "web_auth": cfg.web_auth, "location": cfg.location, + "signal_health": cfg.signal_health, + "automation": cfg.automation, + "stray_frames": cfg.stray_frames, + "metrics": cfg.metrics, + "webhooks": cfg.webhooks, } - unknown_keys: list[str] = [] - for section_name, section_value in raw.items(): - section_instance = section_map.get(section_name) - if section_instance is None: - unknown_keys.append(section_name) - continue - _merge_dataclass(section_instance, section_value) - if isinstance(section_value, dict): - unknown_keys.extend( - _collect_unknown_keys(section_instance, section_value, f"{section_name}.") - ) - - if unknown_keys: - logger.warning( - "Ignoring %d unknown config key(s) in %s: %s. " - "These were not applied -- check for typos against the documented schema.", - len(unknown_keys), - path, - ", ".join(sorted(unknown_keys)), - ) + for section_name, section_instance in section_map.items(): + if section_name in raw: + _merge_dataclass(section_instance, raw[section_name]) _VALID_CONFIG_EXTENSIONS = {".yaml", ".yml"} @@ -449,10 +476,92 @@ def load_config(config_path: Optional[str] = None) -> AppConfig: local = config_path or os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml") _apply_yaml(cfg, _validated_config_path(local)) _resolve_radio_frequency(cfg.radio) + _normalize_webhook_rules(cfg.webhooks) + validate_config_consistency(cfg) + validate_webhook_config(cfg.webhooks) return cfg +def _normalize_webhook_rules(webhooks: WebhookConfig) -> None: + """Convert YAML dict rules into WebhookRuleConfig instances.""" + normalized: list[WebhookRuleConfig] = [] + for item in webhooks.rules: + if isinstance(item, WebhookRuleConfig): + normalized.append(item) + elif isinstance(item, dict): + normalized.append(WebhookRuleConfig(**item)) + webhooks.rules = normalized + + +def validate_webhook_config(webhooks: WebhookConfig) -> None: + """Reject invalid webhook rules at startup.""" + if not webhooks.enabled: + return + + if not webhooks.rules: + raise ValueError( + "webhooks.enabled is true but webhooks.rules is empty" + ) + + seen_names: set[str] = set() + for rule in webhooks.rules: + name = (rule.name or "").strip() + if not name: + raise ValueError("each webhooks.rules entry requires a name") + if name in seen_names: + raise ValueError(f"duplicate webhook rule name: {name!r}") + seen_names.add(name) + + event = (rule.event or "").strip().lower() + if event not in { + "battery_low", + "node_offline", + "node_online", + "keyword_match", + "duty_spike", + "storm_quarantine", + }: + raise ValueError( + f"webhook rule {name!r} has unknown event {rule.event!r}" + ) + + url = (rule.url or "").strip() + if not url: + raise ValueError(f"webhook rule {name!r} requires a url") + if not (url.startswith("http://") or url.startswith("https://")): + raise ValueError( + f"webhook rule {name!r} url must start with http:// or https://" + ) + + if rule.cooldown_seconds < 0: + raise ValueError( + f"webhook rule {name!r} cooldown_seconds must be >= 0" + ) + + if event == "keyword_match" and not (rule.keyword or "").strip(): + raise ValueError( + f"webhook rule {name!r} (keyword_match) requires keyword" + ) + + +def validate_config_consistency(config: AppConfig) -> None: + """Reject impossible radio/location combinations before hardware starts.""" + if not config.radio.gps_pps_enabled: + return + if config.location.source != "uart": + return + pps_tty = os.path.normpath(config.radio.gps_pps_tty_path) + uart_tty = os.path.normpath(config.location.uart_path) + if pps_tty == uart_tty: + raise ValueError( + "radio.gps_pps_enabled and location.source=uart cannot share " + f"the same serial device ({pps_tty!r}). Use location.source=gpsd " + "or static for dashboard coordinates while PPS owns the HAT UART, " + "or disable gps_pps_enabled when using UART for location only." + ) + + def _get_local_yaml_path() -> Path: """Resolve the local.yaml path used for user overrides.""" raw = os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml") diff --git a/src/relay/node_id.py b/src/relay/node_id.py new file mode 100644 index 00000000..f7b5be1d --- /dev/null +++ b/src/relay/node_id.py @@ -0,0 +1,28 @@ +"""Normalize and validate Meshtastic node IDs for relay filter lists.""" + +from __future__ import annotations + +import re + +_NODE_ID_RE = re.compile(r"^[0-9a-f]{8}$", re.IGNORECASE) + + +def normalize_node_id(node_id: str) -> str: + """Strip optional ``!`` prefix and lowercase for consistent matching.""" + return (node_id or "").strip().lower().lstrip("!") + + +def validate_node_ids(node_ids: list[str]) -> list[str]: + """Return normalized unique node IDs or raise ValueError.""" + seen: set[str] = set() + normalized: list[str] = [] + for raw in node_ids: + nid = normalize_node_id(raw) + if not _NODE_ID_RE.match(nid): + raise ValueError( + f"Invalid node ID {raw!r} — expected 8 hex chars (no ! prefix)" + ) + if nid not in seen: + seen.add(nid) + normalized.append(nid) + return normalized diff --git a/src/transmit/meshtastic_builder.py b/src/transmit/meshtastic_builder.py index 2fb20b41..88948a99 100644 --- a/src/transmit/meshtastic_builder.py +++ b/src/transmit/meshtastic_builder.py @@ -9,7 +9,6 @@ import logging import struct -from typing import Sequence from src.decode.crypto_service import CryptoService @@ -17,13 +16,9 @@ BROADCAST_ADDR = 0xFFFFFFFF PORTNUM_TEXT_MESSAGE = 1 -PORTNUM_POSITION = 3 PORTNUM_NODEINFO = 4 -PORTNUM_ROUTING = 5 -PORTNUM_TELEMETRY = 67 -PORTNUM_TRACEROUTE = 70 +PORTNUM_ADMIN = 6 HW_MODEL_PRIVATE_HW = 255 -MAINS_BATTERY_LEVEL = 101 class MeshtasticPacketBuilder: @@ -43,32 +38,26 @@ def build_text_message( hop_limit: int = 3, hop_start: int = 3, want_ack: bool = False, - recipient_public_key: bytes | None = None, ) -> bytes | None: - """Build a complete encrypted TEXT_MESSAGE_APP packet.""" + """Build a complete encrypted TEXT_MESSAGE_APP packet. + + Returns the full on-air byte sequence (header + ciphertext), + or None if encryption fails. + """ inner = self._serialize_data(PORTNUM_TEXT_MESSAGE, text.encode("utf-8")) - ciphertext = self._encrypt_payload( - inner, - packet_id, - source_id, - dest, - channel_key, - channel_hash, - recipient_public_key, + ciphertext = self._crypto.encrypt_meshtastic( + inner, packet_id, source_id, key=channel_key ) if ciphertext is None: logger.error("Encryption failed for packet %d", packet_id) return None - on_air_hash = 0 if recipient_public_key else channel_hash header = self._build_header( - dest, - source_id, - packet_id, + dest, source_id, packet_id, hop_limit=hop_limit, hop_start=hop_start, want_ack=want_ack, - channel_hash=on_air_hash, + channel_hash=channel_hash, ) return header + ciphertext @@ -79,16 +68,20 @@ def build_nodeinfo( long_name: str, short_name: str, hw_model: int = HW_MODEL_PRIVATE_HW, - public_key: bytes | None = None, channel_key: bytes | None = None, channel_hash: int = 0x08, hop_limit: int = 3, hop_start: int = 3, ) -> bytes | None: - """Build a broadcast NODEINFO_APP packet announcing this node.""" + """Build a broadcast NODEINFO_APP packet announcing this node. + + Wraps a serialized ``User`` protobuf in the standard encrypted + Meshtastic envelope. Recipients use this to populate their + contact list so DMs can be addressed by name. + """ node_id_str = f"!{source_id:08x}" user_payload = self._serialize_user( - node_id_str, long_name, short_name, hw_model, public_key + node_id_str, long_name, short_name, hw_model ) inner = self._serialize_data(PORTNUM_NODEINFO, user_payload) ciphertext = self._crypto.encrypt_meshtastic( @@ -99,9 +92,7 @@ def build_nodeinfo( return None header = self._build_header( - BROADCAST_ADDR, - source_id, - packet_id, + BROADCAST_ADDR, source_id, packet_id, hop_limit=hop_limit, hop_start=hop_start, want_ack=False, @@ -109,316 +100,54 @@ def build_nodeinfo( ) return header + ciphertext - def build_routing_ack( + def build_admin_message( self, - source_id: int, + admin_payload: bytes, dest: int, - packet_id: int, - request_id: int, - channel_key: bytes | None = None, - channel_hash: int = 0x08, - hop_limit: int = 3, - hop_start: int = 3, - recipient_public_key: bytes | None = None, - ) -> bytes | None: - """Build a ROUTING ACK for an inbound direct message.""" - routing_payload = b"" - inner = self._serialize_data( - PORTNUM_ROUTING, routing_payload, request_id=request_id - ) - ciphertext = self._encrypt_payload( - inner, - packet_id, - source_id, - dest, - channel_key, - channel_hash, - recipient_public_key, - ) - if ciphertext is None: - return None - on_air_hash = 0 if recipient_public_key else channel_hash - header = self._build_header( - dest, - source_id, - packet_id, - hop_limit=hop_limit, - hop_start=hop_start, - want_ack=False, - channel_hash=on_air_hash, - ) - return header + ciphertext - - def build_telemetry( - self, - source_id: int, - packet_id: int, - *, - battery_level: int = MAINS_BATTERY_LEVEL, - voltage: float = 5.0, - channel_utilization: float = 0.0, - air_util_tx: float = 0.0, - uptime_seconds: int = 0, - channel_key: bytes | None = None, - channel_hash: int = 0x08, - hop_limit: int = 3, - hop_start: int = 3, - ) -> bytes | None: - """Build a broadcast TELEMETRY device_metrics packet.""" - try: - from meshtastic.protobuf import telemetry_pb2 - - telem = telemetry_pb2.Telemetry() - telem.device_metrics.battery_level = battery_level - telem.device_metrics.voltage = voltage - telem.device_metrics.channel_utilization = channel_utilization - telem.device_metrics.air_util_tx = air_util_tx - telem.device_metrics.uptime_seconds = uptime_seconds - payload = telem.SerializeToString() - except Exception: - logger.exception("Telemetry protobuf build failed") - return None - - inner = self._serialize_data(PORTNUM_TELEMETRY, payload) - ciphertext = self._crypto.encrypt_meshtastic( - inner, packet_id, source_id, key=channel_key - ) - if ciphertext is None: - return None - header = self._build_header( - BROADCAST_ADDR, - source_id, - packet_id, - hop_limit=hop_limit, - hop_start=hop_start, - channel_hash=channel_hash, - ) - return header + ciphertext - - def build_telemetry_reply( - self, source_id: int, - dest: int, packet_id: int, - request_id: int, - *, - variant: str = "device_metrics", - battery_level: int = MAINS_BATTERY_LEVEL, - voltage: float = 5.0, - channel_utilization: float = 0.0, - air_util_tx: float = 0.0, - uptime_seconds: int = 0, - num_packets_tx: int = 0, - num_packets_rx: int = 0, - num_packets_rx_bad: int = 0, - num_online_nodes: int = 0, - num_total_nodes: int = 0, - num_tx_relay: int = 0, - noise_floor: int | None = None, - telemetry_time: int = 0, - channel_key: bytes | None = None, - channel_hash: int = 0x08, + admin_key: bytes, + admin_channel_name: str = "admin", hop_limit: int = 3, hop_start: int = 3, - recipient_public_key: bytes | None = None, + want_ack: bool = True, ) -> bytes | None: - """Build a unicast TELEMETRY reply to a telemetry request.""" - try: - from meshtastic.protobuf import telemetry_pb2 - - telem = telemetry_pb2.Telemetry() - if telemetry_time: - telem.time = telemetry_time - if variant == "local_stats": - ls = telem.local_stats - ls.uptime_seconds = uptime_seconds - ls.channel_utilization = channel_utilization - ls.air_util_tx = air_util_tx - ls.num_packets_tx = num_packets_tx - ls.num_packets_rx = num_packets_rx - ls.num_packets_rx_bad = num_packets_rx_bad - ls.num_online_nodes = num_online_nodes - ls.num_total_nodes = num_total_nodes - ls.num_tx_relay = num_tx_relay - if noise_floor is not None: - ls.noise_floor = noise_floor - else: - telem.device_metrics.battery_level = battery_level - telem.device_metrics.voltage = voltage - telem.device_metrics.channel_utilization = channel_utilization - telem.device_metrics.air_util_tx = air_util_tx - telem.device_metrics.uptime_seconds = uptime_seconds - payload = telem.SerializeToString() - except Exception: - logger.exception("Telemetry reply protobuf build failed") - return None - - inner = self._serialize_data( - PORTNUM_TELEMETRY, payload, request_id=request_id - ) - ciphertext = self._encrypt_payload( - inner, - packet_id, - source_id, - dest, - channel_key, - channel_hash, - recipient_public_key, + """Build an encrypted ADMIN_APP packet using the admin channel PSK.""" + inner = self._serialize_data(PORTNUM_ADMIN, admin_payload) + channel_hash = self._crypto.compute_channel_hash( + admin_channel_name, admin_key ) - if ciphertext is None: - return None - on_air_hash = 0 if recipient_public_key else channel_hash - header = self._build_header( - dest, - source_id, - packet_id, - hop_limit=hop_limit, - hop_start=hop_start, - channel_hash=on_air_hash, - ) - return header + ciphertext - - def build_position( - self, - source_id: int, - packet_id: int, - latitude: float, - longitude: float, - altitude: float | None = None, - channel_key: bytes | None = None, - channel_hash: int = 0x08, - hop_limit: int = 3, - hop_start: int = 3, - ) -> bytes | None: - """Build a broadcast POSITION packet.""" - try: - from meshtastic.protobuf import mesh_pb2 - - pos = mesh_pb2.Position() - pos.latitude_i = int(latitude * 1e7) - pos.longitude_i = int(longitude * 1e7) - if altitude is not None: - pos.altitude = int(altitude) - payload = pos.SerializeToString() - except Exception: - logger.exception("Position protobuf build failed") - return None - - inner = self._serialize_data(PORTNUM_POSITION, payload) ciphertext = self._crypto.encrypt_meshtastic( - inner, packet_id, source_id, key=channel_key + inner, packet_id, source_id, key=admin_key ) if ciphertext is None: - return None - header = self._build_header( - BROADCAST_ADDR, - source_id, - packet_id, - hop_limit=hop_limit, - hop_start=hop_start, - channel_hash=channel_hash, - ) - return header + ciphertext - - def build_traceroute_reply( - self, - source_id: int, - dest: int, - packet_id: int, - route_nodes: Sequence[int], - *, - request_id: int = 0, - snr_towards: Sequence[int] | None = None, - route_back: Sequence[int] | None = None, - snr_back: Sequence[int] | None = None, - channel_key: bytes | None = None, - channel_hash: int = 0x08, - hop_limit: int = 3, - hop_start: int = 3, - recipient_public_key: bytes | None = None, - ) -> bytes | None: - """Build a TRACEROUTE reply with a RouteDiscovery payload.""" - try: - from meshtastic.protobuf import mesh_pb2 - - rd = mesh_pb2.RouteDiscovery() - for node in route_nodes: - rd.route.append(node) - if snr_towards: - rd.snr_towards.extend(snr_towards) - if route_back: - rd.route_back.extend(route_back) - if snr_back: - rd.snr_back.extend(snr_back) - payload = rd.SerializeToString() - except Exception: - logger.exception("Traceroute protobuf build failed") + logger.error("Admin encryption failed for packet %d", packet_id) return None - inner = self._serialize_data( - PORTNUM_TRACEROUTE, payload, request_id=request_id - ) - ciphertext = self._encrypt_payload( - inner, - packet_id, - source_id, - dest, - channel_key, - channel_hash, - recipient_public_key, - ) - if ciphertext is None: - return None - on_air_hash = 0 if recipient_public_key else channel_hash header = self._build_header( dest, source_id, packet_id, hop_limit=hop_limit, hop_start=hop_start, - channel_hash=on_air_hash, + want_ack=want_ack, + channel_hash=channel_hash, ) return header + ciphertext - def _encrypt_payload( - self, - inner: bytes, - packet_id: int, - source_id: int, - dest: int, - channel_key: bytes | None, - channel_hash: int, - recipient_public_key: bytes | None, - ) -> bytes | None: - if ( - recipient_public_key - and dest != BROADCAST_ADDR - and self._crypto.has_pki() - ): - return self._crypto.encrypt_meshtastic_pki( - inner, - packet_id, - source_id, - recipient_public_key, - ) - return self._crypto.encrypt_meshtastic( - inner, packet_id, source_id, key=channel_key - ) - @staticmethod - def _serialize_data( - portnum: int, payload: bytes, request_id: int = 0 - ) -> bytes: - """Serialize a mesh_pb2.Data protobuf manually.""" + def _serialize_data(portnum: int, payload: bytes) -> bytes: + """Serialize a mesh_pb2.Data protobuf manually. + + Avoids importing the full protobuf library at runtime. + Wire format: field 1 (portnum) varint + field 2 (payload) bytes. + """ result = bytearray() result.append(0x08) result.extend(_encode_varint(portnum)) result.append(0x12) result.extend(_encode_varint(len(payload))) result.extend(payload) - if request_id: - result.append(0x35) - result.extend(struct.pack(" bytes: - """Serialize a mesh_pb2.User protobuf manually.""" + """Serialize a mesh_pb2.User protobuf manually. + + Wire format used here: field 1 (id, string), field 2 + (long_name, string), field 3 (short_name, string), and field 5 + (hw_model, varint). The ``macaddr`` field (4) is intentionally + omitted since the Meshpoint has no canonical radio MAC and + clients tolerate its absence. + """ result = bytearray() for tag, text in ( (0x0A, node_id_str), @@ -442,10 +177,6 @@ def _serialize_user( result.extend(encoded) result.append(0x28) result.extend(_encode_varint(hw_model)) - if public_key: - result.append(0x42) - result.extend(_encode_varint(len(public_key))) - result.extend(public_key) return bytes(result) @staticmethod diff --git a/src/transmit/tx_service.py b/src/transmit/tx_service.py index 79b646fc..3bc0f438 100644 --- a/src/transmit/tx_service.py +++ b/src/transmit/tx_service.py @@ -17,7 +17,6 @@ from src.models.packet import Protocol from src.transmit.duty_cycle import DutyCycleTracker -from src.transmit.reply_hop_policy import MeshtasticReplyHopPolicy logger = logging.getLogger(__name__) @@ -88,8 +87,6 @@ def __init__( self._source_node_id = self._resolve_node_id() if persist_derived_node_id: self._persist_derived_node_id_if_needed() - self._device_metrics_provider = None - self._local_stats_provider = None @property def meshtastic_enabled(self) -> bool: @@ -107,35 +104,6 @@ def meshcore_enabled(self) -> bool: def source_node_id(self) -> int: return self._source_node_id - def set_telemetry_reply_providers( - self, - device_metrics_provider=None, - local_stats_provider=None, - ) -> None: - self._device_metrics_provider = device_metrics_provider - self._local_stats_provider = local_stats_provider - - @staticmethod - def _recipient_pubkey_for_reply(original, requester: int, crypto) -> bytes | None: - """Use PKI only when the inbound request was PKI-encrypted (ch=0x00).""" - if original.channel_hash != 0: - return None - if crypto is None: - return None - key = crypto.lookup_public_key(requester) - if key is None: - key = crypto.refresh_public_key_from_db(requester) - return key - - @staticmethod - def _reply_hop_fields(original, configured_hop_limit: int) -> tuple[int, int]: - """Mirror firmware hop limits for want_response replies.""" - return MeshtasticReplyHopPolicy.reply_hop_fields( - original.hop_limit, - original.hop_start, - configured_hop_limit, - ) - @property def node_id_source(self) -> str: """Where the resolved node_id came from: 'config', 'derived', or 'random'.""" @@ -190,9 +158,6 @@ async def send_nodeinfo( packet_id = self._next_packet_id() channel_hash, channel_key = self._resolve_channel(0) - public_key = None - if self._crypto is not None: - public_key = self._crypto.public_key try: nodeinfo_hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT @@ -202,7 +167,6 @@ async def send_nodeinfo( long_name=long_name, short_name=short_name, hw_model=hw_model, - public_key=public_key, channel_key=channel_key, channel_hash=channel_hash, hop_limit=nodeinfo_hop_limit, @@ -287,9 +251,6 @@ async def _send_meshtastic( dest_int = self._resolve_destination(destination, Protocol.MESHTASTIC) packet_id = self._next_packet_id() channel_hash, channel_key = self._resolve_channel(channel) - recipient_pubkey = None - if dest_int != BROADCAST_ADDR_MT and self._crypto is not None: - recipient_pubkey = self._crypto.lookup_public_key(dest_int) hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT packet_bytes = builder.build_text_message( @@ -302,7 +263,6 @@ async def _send_meshtastic( hop_limit=hop_limit, hop_start=hop_limit, want_ack=want_ack, - recipient_public_key=recipient_pubkey, ) if packet_bytes is None: return SendResult( @@ -368,285 +328,78 @@ async def _send_meshtastic( error=f"lgw_send returned {result_code}", ) - async def send_routing_ack(self, original) -> SendResult: - """Reply with a Meshtastic routing ACK to an inbound DM.""" + async def send_admin_message( + self, + admin_payload: bytes, + destination: int | str, + admin_key: bytes, + admin_channel_name: str = "admin", + want_ack: bool = True, + ) -> SendResult: + """Transmit an ADMIN_APP packet encrypted with the admin channel PSK.""" if not self.meshtastic_enabled: - return SendResult(success=False, protocol="meshtastic", error="TX unavailable") - - builder = self._get_builder() - if builder is None or not hasattr(builder, "build_routing_ack"): - return SendResult(success=False, protocol="meshtastic", error="Builder unavailable") - - try: - request_id = int(original.packet_id, 16) - dest = int(original.source_id, 16) - except ValueError: - return SendResult(success=False, protocol="meshtastic", error="Invalid packet ids") - - packet_id = self._next_packet_id() - channel_hash = original.channel_hash - _, channel_key = self._resolve_channel_by_hash(channel_hash) - recipient_pubkey = self._recipient_pubkey_for_reply( - original, dest, self._crypto - ) - if channel_hash == 0 and recipient_pubkey is None: return SendResult( success=False, protocol="meshtastic", - error="No public_key for PKI routing ACK recipient", + error="Meshtastic TX not available", ) - configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT - hop_limit, hop_start = self._reply_hop_fields(original, configured) - - packet_bytes = builder.build_routing_ack( - source_id=self._source_node_id, - dest=dest, - packet_id=packet_id, - request_id=request_id, - channel_key=channel_key, - channel_hash=channel_hash, - hop_limit=hop_limit, - hop_start=hop_start, - recipient_public_key=recipient_pubkey, - ) - if packet_bytes is None: - return SendResult(success=False, protocol="meshtastic", error="ACK build failed") - - return await self._send_built_packet(packet_bytes, packet_id, label="routing ACK") - - async def send_traceroute_reply(self, original) -> SendResult: - """Reply to a traceroute probe addressed to this node.""" - if not self.meshtastic_enabled: - return SendResult(success=False, protocol="meshtastic", error="TX unavailable") builder = self._get_builder() - if builder is None or not hasattr(builder, "build_traceroute_reply"): - return SendResult(success=False, protocol="meshtastic", error="Builder unavailable") - - try: - requester = int(original.source_id, 16) - request_id = int(original.packet_id, 16) - except ValueError: - return SendResult(success=False, protocol="meshtastic", error="Invalid source id") - - rx_snr = ( - float(original.signal.snr) - if original.signal and original.signal.snr is not None - else None - ) - route_nodes, snr_towards, route_back, snr_back = ( - self._build_traceroute_reply_data(original, rx_snr) - ) - - packet_id = self._next_packet_id() - channel_hash = original.channel_hash - _, channel_key = self._resolve_channel_by_hash(channel_hash) - recipient_pubkey = self._recipient_pubkey_for_reply( - original, requester, self._crypto - ) - if channel_hash == 0 and recipient_pubkey is None: + if builder is None or not hasattr(builder, "build_admin_message"): return SendResult( success=False, protocol="meshtastic", - error="No public_key for PKI traceroute reply recipient", + error="Admin packet builder unavailable", ) - configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT - hop_limit, hop_start = self._reply_hop_fields(original, configured) - packet_bytes = builder.build_traceroute_reply( - source_id=self._source_node_id, - dest=requester, - packet_id=packet_id, - route_nodes=route_nodes, - request_id=request_id, - snr_towards=snr_towards or None, - route_back=route_back or None, - snr_back=snr_back or None, - channel_key=channel_key, - channel_hash=channel_hash, - hop_limit=hop_limit, - hop_start=hop_start, - recipient_public_key=recipient_pubkey, - ) - if packet_bytes is None: + dest_int = self._resolve_destination(destination, Protocol.MESHTASTIC) + if dest_int in RESERVED_NODE_IDS: return SendResult( - success=False, protocol="meshtastic", error="Traceroute build failed" + success=False, + protocol="meshtastic", + error="Invalid destination node", ) - return await self._send_built_packet( - packet_bytes, packet_id, label="traceroute reply" - ) - - async def send_telemetry( - self, - *, - battery_level: int = 101, - voltage: float = 5.0, - channel_utilization: float = 0.0, - air_util_tx: float = 0.0, - uptime_seconds: int = 0, - ) -> SendResult: - if not self.meshtastic_enabled: - return SendResult(success=False, protocol="meshtastic", error="TX unavailable") - - builder = self._get_builder() - if builder is None or not hasattr(builder, "build_telemetry"): - return SendResult(success=False, protocol="meshtastic", error="Builder unavailable") - packet_id = self._next_packet_id() - channel_hash, channel_key = self._resolve_channel(0) hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT - - packet_bytes = builder.build_telemetry( - source_id=self._source_node_id, - packet_id=packet_id, - battery_level=battery_level, - voltage=voltage, - channel_utilization=channel_utilization, - air_util_tx=air_util_tx, - uptime_seconds=uptime_seconds, - channel_key=channel_key, - channel_hash=channel_hash, - hop_limit=hop_limit, - hop_start=hop_limit, - ) - if packet_bytes is None: - return SendResult(success=False, protocol="meshtastic", error="Telemetry build failed") - - return await self._send_built_packet(packet_bytes, packet_id, label="telemetry") - - async def send_telemetry_reply(self, original) -> SendResult: - """Reply to an inbound telemetry request addressed to this node.""" - if not self.meshtastic_enabled: - return SendResult(success=False, protocol="meshtastic", error="TX unavailable") - - builder = self._get_builder() - if builder is None or not hasattr(builder, "build_telemetry_reply"): - return SendResult(success=False, protocol="meshtastic", error="Builder unavailable") - try: - requester = int(original.source_id, 16) - request_id = int(original.packet_id, 16) - except ValueError: - return SendResult(success=False, protocol="meshtastic", error="Invalid source id") - - payload = original.decoded_payload or {} - variant = payload.get("telemetry_variant", "device_metrics") - if variant == "local_stats": - metrics = ( - self._local_stats_provider() if self._local_stats_provider else {} - ) - else: - metrics = ( - self._device_metrics_provider() if self._device_metrics_provider else {} + packet_bytes = builder.build_admin_message( + admin_payload=admin_payload, + dest=dest_int, + source_id=self._source_node_id, + packet_id=packet_id, + admin_key=admin_key, + admin_channel_name=admin_channel_name, + hop_limit=hop_limit, + hop_start=hop_limit, + want_ack=want_ack, ) - - packet_id = self._next_packet_id() - channel_hash = original.channel_hash - _, channel_key = self._resolve_channel_by_hash(channel_hash) - recipient_pubkey = self._recipient_pubkey_for_reply( - original, requester, self._crypto - ) - if channel_hash == 0 and recipient_pubkey is None: + except Exception as exc: + logger.exception("Admin packet build failed") return SendResult( success=False, protocol="meshtastic", - error="No public_key for PKI telemetry reply recipient", - ) - configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT - hop_limit, hop_start = self._reply_hop_fields(original, configured) - - build_kwargs = { - "source_id": self._source_node_id, - "dest": requester, - "packet_id": packet_id, - "request_id": request_id, - "variant": variant, - "telemetry_time": int(time.time()), - "channel_key": channel_key, - "channel_hash": channel_hash, - "hop_limit": hop_limit, - "hop_start": hop_start, - "recipient_public_key": recipient_pubkey, - } - if variant == "local_stats": - build_kwargs.update( - { - "uptime_seconds": int(metrics.get("uptime_seconds", 0)), - "channel_utilization": float( - metrics.get("channel_utilization", 0.0) - ), - "air_util_tx": float(metrics.get("air_util_tx", 0.0)), - "num_packets_tx": int(metrics.get("num_packets_tx", 0)), - "num_packets_rx": int(metrics.get("num_packets_rx", 0)), - "num_packets_rx_bad": int(metrics.get("num_packets_rx_bad", 0)), - "num_online_nodes": int(metrics.get("num_online_nodes", 0)), - "num_total_nodes": int(metrics.get("num_total_nodes", 0)), - "num_tx_relay": int(metrics.get("num_tx_relay", 0)), - } - ) - noise_floor = metrics.get("noise_floor") - if noise_floor is not None: - build_kwargs["noise_floor"] = int(noise_floor) - else: - build_kwargs.update( - { - "battery_level": int(metrics.get("battery_level", 101)), - "voltage": float(metrics.get("voltage", 5.0)), - "channel_utilization": float( - metrics.get("channel_utilization", 0.0) - ), - "air_util_tx": float(metrics.get("air_util_tx", 0.0)), - "uptime_seconds": int(metrics.get("uptime_seconds", 0)), - } + packet_id=f"{packet_id:08x}", + error=f"Admin packet build failed: {exc}", ) - packet_bytes = builder.build_telemetry_reply(**build_kwargs) if packet_bytes is None: return SendResult( - success=False, protocol="meshtastic", error="Telemetry reply build failed" + success=False, + protocol="meshtastic", + packet_id=f"{packet_id:08x}", + error="Admin packet build returned None", ) - return await self._send_built_packet( - packet_bytes, packet_id, label="telemetry reply" - ) - - async def send_position( - self, - latitude: float, - longitude: float, - altitude: float | None = None, - ) -> SendResult: - if not self.meshtastic_enabled: - return SendResult(success=False, protocol="meshtastic", error="TX unavailable") - - builder = self._get_builder() - if builder is None or not hasattr(builder, "build_position"): - return SendResult(success=False, protocol="meshtastic", error="Builder unavailable") - - packet_id = self._next_packet_id() - channel_hash, channel_key = self._resolve_channel(0) - hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT - - packet_bytes = builder.build_position( - source_id=self._source_node_id, - packet_id=packet_id, - latitude=latitude, - longitude=longitude, - altitude=altitude, - channel_key=channel_key, - channel_hash=channel_hash, - hop_limit=hop_limit, - hop_start=hop_limit, + logger.info( + "TX ADMIN: dest=%08x src=%08x id=%08x channel=%r len=%d", + dest_int, + self._source_node_id, + packet_id, + admin_channel_name, + len(packet_bytes), ) - if packet_bytes is None: - return SendResult(success=False, protocol="meshtastic", error="Position build failed") - return await self._send_built_packet(packet_bytes, packet_id, label="position") - - async def _send_built_packet( - self, packet_bytes: bytes, packet_id: int, *, label: str - ) -> SendResult: tx_pkt = self._build_hal_packet(packet_bytes) airtime_ms = await self._get_airtime(tx_pkt) @@ -663,7 +416,6 @@ async def _send_built_packet( if result_code == 0: if self._duty: self._duty.record_tx(airtime_ms) - logger.info("TX %s OK: id=%08x airtime=%dms", label, packet_id, airtime_ms) return SendResult( success=True, protocol="meshtastic", @@ -676,6 +428,7 @@ async def _send_built_packet( protocol="meshtastic", packet_id=f"{packet_id:08x}", error=f"lgw_send returned {result_code}", + airtime_ms=airtime_ms, ) async def send_raw_relay( @@ -982,68 +735,6 @@ def _resolve_channel(self, channel: int) -> tuple[int, bytes | None]: logger.debug("Channel hash fallback to 0x08", exc_info=True) return 0x08, None - @staticmethod - def _build_traceroute_reply_data( - original, rx_snr: float | None - ) -> tuple[list[int], list[int], list[int], list[int]]: - """Build RouteDiscovery fields like Meshtastic firmware at the destination. - - Relays append node ids plus SNR on the way in. The target only appends the - final-hop SNR (SNRonly) and does not add itself to ``route``. Meshtastic 2.5+ - also expects ``route_back`` / ``snr_back`` on the response. - """ - payload = original.decoded_payload or {} - route_nodes: list[int] = [] - for node_hex in payload.get("route") or []: - try: - route_nodes.append(int(node_hex, 16)) - except (TypeError, ValueError): - continue - - snr_towards: list[int] = [] - for val in payload.get("snr_towards") or []: - try: - snr_towards.append(int(val)) - except (TypeError, ValueError): - continue - - encoded_snr: int | None = None - if rx_snr is not None: - encoded_snr = int(round(float(rx_snr) * 4)) - - if encoded_snr is not None: - snr_towards.append(encoded_snr) - - requester = int(original.source_id, 16) - route_back = [requester] - snr_back = [encoded_snr] if encoded_snr is not None else [] - - return route_nodes, snr_towards, route_back, snr_back - - def _resolve_channel_by_hash(self, channel_hash: int) -> tuple[int, bytes | None]: - """Resolve encryption key from a captured on-air channel hash.""" - if self._crypto is None: - return channel_hash, None - try: - primary_name = self._primary_channel_name - all_keys = self._crypto.get_all_keys() - if all_keys: - primary_hash = self._crypto.compute_channel_hash( - primary_name, all_keys[0] - ) - if primary_hash == channel_hash: - return channel_hash, all_keys[0] - for i, (ch_name, key) in enumerate(self._crypto._keys.items(), start=1): - if i < len(all_keys): - h = self._crypto.compute_channel_hash(ch_name, all_keys[i]) - if h == channel_hash: - return channel_hash, all_keys[i] - if all_keys: - return channel_hash, all_keys[0] - except Exception: - logger.debug("Channel-by-hash lookup failed", exc_info=True) - return channel_hash, None - def _get_preset_name(self) -> str: """Derive the Meshtastic modem preset display name from radio params.""" if not self._radio_config: diff --git a/tests/test_admin_reader.py b/tests/test_admin_reader.py new file mode 100644 index 00000000..e5c0370a --- /dev/null +++ b/tests/test_admin_reader.py @@ -0,0 +1,133 @@ +"""Unit tests for remote ADMIN config read (PR 15).""" +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from meshtastic.protobuf import admin_pb2, config_pb2, mesh_pb2 + +from src.admin.config_decode import _redact_value, message_to_redacted_dict +from src.admin.pending_store import PendingConfigStore +from src.admin.reader import AdminConfigError, AdminConfigReader, PORTNUM_ADMIN +from src.decode.crypto_service import CryptoService +from src.models.packet import Packet, PacketType, Protocol +from src.transmit.meshtastic_builder import MeshtasticPacketBuilder + + +class TestAdminMessageBuild(unittest.TestCase): + def test_device_config_request_serializes(self): + msg = admin_pb2.AdminMessage() + msg.get_config_request = admin_pb2.AdminMessage.DEVICE_CONFIG + self.assertEqual(msg.SerializeToString(), b"\x28\x00") + + def test_owner_request_serializes(self): + msg = admin_pb2.AdminMessage() + msg.get_owner_request = True + self.assertEqual(msg.SerializeToString(), b"\x18\x01") + + +class TestConfigRedaction(unittest.TestCase): + def test_psk_fields_redacted(self): + raw = { + "security": {"private_key": "secret", "public_key": "pub"}, + "lora": {"bandwidth": 250, "psk": "hide-me"}, + } + out = _redact_value(raw) + self.assertEqual(out["security"]["private_key"], "***") + self.assertEqual(out["security"]["public_key"], "***") + self.assertEqual(out["lora"]["psk"], "***") + self.assertEqual(out["lora"]["bandwidth"], 250) + + def test_message_to_dict_keeps_safe_fields(self): + cfg = config_pb2.Config() + cfg.lora.bandwidth = 250 + out = message_to_redacted_dict(cfg) + self.assertEqual(out["lora"]["bandwidth"], 250) + + +class TestPendingStore(unittest.TestCase): + def test_debounce_blocks_second_request(self): + store = PendingConfigStore() + store.begin("a3f2b1c0", section="device", packet_id="00000001", expected_response="get_config_response") + ok, _ = store.can_request("a3f2b1c0") + self.assertFalse(ok) + + +class TestAdminPacketBuild(unittest.TestCase): + def test_build_admin_packet_encrypts(self): + crypto = CryptoService(default_key_b64="AQ==") + admin_key = crypto._expand_key(b"\x01") + builder = MeshtasticPacketBuilder(crypto) + admin_msg = admin_pb2.AdminMessage() + admin_msg.get_config_request = admin_pb2.AdminMessage.DEVICE_CONFIG + packet = builder.build_admin_message( + admin_payload=admin_msg.SerializeToString(), + dest=0xA3F2B1C0, + source_id=0x12345678, + packet_id=99, + admin_key=admin_key, + admin_channel_name="admin", + ) + self.assertIsNotNone(packet) + self.assertGreater(len(packet), 16) + + +class TestAdminResponseConsume(unittest.IsolatedAsyncioTestCase): + async def test_consumes_matching_admin_response(self): + crypto = CryptoService(default_key_b64="AQ==") + admin_key = crypto._expand_key(b"\x01") + builder = MeshtasticPacketBuilder(crypto) + + response_admin = admin_pb2.AdminMessage() + response_admin.get_config_response.device.role = 2 + inner = builder._serialize_data( + PORTNUM_ADMIN, response_admin.SerializeToString() + ) + + packet_id = 42 + source_id = 0xA3F2B1C0 + ciphertext = crypto.encrypt_meshtastic(inner, packet_id, source_id, key=admin_key) + header = builder._build_header( + 0x12345678, + source_id, + packet_id, + channel_hash=crypto.compute_channel_hash("admin", admin_key), + ) + raw = header + ciphertext + + tx = MagicMock() + tx.meshtastic_enabled = True + tx.send_admin_message = AsyncMock( + return_value=MagicMock(success=True, packet_id="0000002a", error="") + ) + + reader = AdminConfigReader( + tx_service=tx, + crypto=crypto, + admin_key_b64="AQ==", + admin_channel_name="admin", + local_node_id=0x12345678, + ) + await reader.request_config("!a3f2b1c0", section="device") + + packet = Packet( + packet_id="0000002a", + source_id="a3f2b1c0", + destination_id="12345678", + protocol=Protocol.MESHTASTIC, + packet_type=PacketType.ADMIN, + raw_radio_packet=raw, + ) + reader.try_consume_packet(packet) + + status = reader.get_status("a3f2b1c0") + self.assertEqual(status["status"], "complete") + self.assertIn("config", status["config"]) + + +class TestAdminReaderUnavailable(unittest.IsolatedAsyncioTestCase): + async def test_503_without_admin_key(self): + reader = AdminConfigReader(tx_service=MagicMock(), crypto=CryptoService()) + with self.assertRaises(AdminConfigError) as ctx: + await reader.request_config("a3f2b1c0") + self.assertEqual(ctx.exception.status_code, 503) diff --git a/tests/test_admin_routes.py b/tests/test_admin_routes.py new file mode 100644 index 00000000..b891624a --- /dev/null +++ b/tests/test_admin_routes.py @@ -0,0 +1,90 @@ +"""API route tests for remote ADMIN config read.""" +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from src.admin.reader import AdminConfigError, AdminConfigReader + + +class TestAdminRoutes(unittest.TestCase): + def setUp(self): + try: + from fastapi import FastAPI + from fastapi.testclient import TestClient + from src.api.auth.dependencies import require_admin + from src.api.auth.jwt_session import ROLE_ADMIN, SessionClaims + from src.api.routes import admin_routes + except ImportError as exc: + raise unittest.SkipTest(f"API test deps unavailable: {exc}") from exc + + self.admin_routes = admin_routes + + def _admin_claims() -> SessionClaims: + return SessionClaims(subject="admin", role=ROLE_ADMIN, session_version=1) + + self.reader = MagicMock(spec=AdminConfigReader) + self.reader.available = True + self.reader.get_status.return_value = { + "node_id": "a3f2b1c0", + "status": "idle", + "section": None, + "config": None, + "error": "", + } + self.reader.request_config = AsyncMock( + return_value={ + "node_id": "a3f2b1c0", + "request_id": "abc", + "status": "pending", + "section": "device", + "packet_id": "00000001", + } + ) + + admin_routes.init_routes(reader=self.reader) + self.app = FastAPI() + self.app.dependency_overrides[require_admin] = _admin_claims + from src.api.audit.dependencies import get_audit_writer + + audit = MagicMock() + + class _Ctx: + def __enter__(self): + return self + + def __exit__(self, *args, **kwargs): + return False + + audit.timed_action.return_value = _Ctx() + self.app.dependency_overrides[get_audit_writer] = lambda: audit + self.app.include_router(admin_routes.router) + self.client = TestClient(self.app) + + def tearDown(self): + self.admin_routes.reset_routes() + + def test_status_endpoint(self): + self.reader.available = True + resp = self.client.get("/api/admin/remote-config/status") + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.json()["available"]) + + def test_request_config_success(self): + resp = self.client.post( + "/api/admin/nodes/a3f2b1c0/config/request", + json={"section": "device"}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "pending") + self.reader.request_config.assert_awaited_once() + + def test_request_config_maps_admin_error(self): + self.reader.request_config = AsyncMock( + side_effect=AdminConfigError("no key", status_code=503) + ) + resp = self.client.post( + "/api/admin/nodes/a3f2b1c0/config/request", + json={"section": "device"}, + ) + self.assertEqual(resp.status_code, 503)