diff --git a/AGENTS.md b/AGENTS.md index 9fca0cb..1232bdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -341,19 +341,20 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override | | POST | `/api/contacts/{public_key}/trace` | Trace route to contact | | POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route | -| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater | +| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater (escalates to one flood retry if the first attempt draws no reply) | | POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry | | POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors | | POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL | | POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI | | POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI | +| POST | `/api/contacts/{public_key}/repeater/regions` | Fetch repeater region hierarchy via CLI, falling back to the guest anon flood-allowed region names (`source`: `cli` or `anon`) | | POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals | | POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info | | GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) | | POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) | | GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) | -| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server | +| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server (escalates to one flood retry if the first attempt draws no reply) | | POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | | POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries | @@ -392,7 +393,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) | | DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) | | POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts | -| GET | `/api/statistics` | Aggregated mesh network statistics | +| GET | `/api/statistics` | Aggregated mesh network statistics, including `region_scope_24h` regional flood-scope adoption | | GET | `/api/push/vapid-public-key` | VAPID public key for browser push subscription | | POST | `/api/push/subscribe` | Register/upsert a push subscription | | GET | `/api/push/subscriptions` | List all push subscriptions | @@ -513,6 +514,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift | | `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE | | `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. | +| `MESHCORE_SKIP_POST_CONNECT_SYNC` | `false` | Debug/diagnostic escape hatch: skip the contact/channel sync-and-offload, startup advertisement, and pending-message drain during post-connect setup, and do not start the periodic sync/advert/message-poll/telemetry background loops. Handler registration, key export, time sync, flood-scope apply, and auto message fetching still run. Useful when the radio's contact/channel state must be left untouched; not for normal operation. | | `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). | | `MESHCORE_VAPID_SUBJECT` | `mailto:noreply@meshcore.local` | Subject (`sub`) claim for Web Push VAPID tokens; must be a `mailto:` or `https:` contact. Apple's push service (APNs) rejects the default `.local` domain with `403 BadJwtToken`, so iOS/Safari operators must set this to a real address. Google FCM (Chrome/Android) accepts the default. | diff --git a/app/AGENTS.md b/app/AGENTS.md index d5d6a47..667274c 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -24,13 +24,14 @@ Keep it aligned with `app/` source files and router behavior. ```text app/ ├── main.py # App startup/lifespan, router registration, static frontend mounting +├── api_docs.py # OpenAPI description/tag metadata and docs route registration ├── config.py # Env-driven runtime settings ├── channel_constants.py # Public/default channel constants shared across sync/send logic ├── database.py # SQLite connection + base schema + migration runner ├── migrations/ # Schema migrations (SQLite user_version, per-version modules) ├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert) ├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces -├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout, push_subscriptions, repeater_telemetry) +├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout, push_subscriptions, repeater_telemetry, contact_telemetry) ├── services/ # Shared orchestration/domain services │ ├── messages.py # Shared message creation, dedup, ACK application │ ├── message_send.py # Direct send, channel send, resend workflows @@ -38,6 +39,7 @@ app/ │ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state │ ├── dm_ack_tracker.py # Pending DM ACK state │ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring +│ ├── flood_scope.py # Firmware-version-aware flood-scope set/clear command seam │ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers │ ├── radio_commands.py # Radio config/private-key command workflows │ ├── radio_stats.py # In-memory local radio stats sampling and noise-floor history @@ -58,6 +60,7 @@ app/ ├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler ├── path_utils.py # Path hex rendering and hop-width helpers ├── region_scope.py # Normalize/validate regional flood-scope values +├── region_resolver.py # Recompute transport codes per known region to name a packet's region ├── keystore.py # Ephemeral private/public key storage for DM decryption ├── frontend_static.py # Mount/serve built frontend (production) └── routers/ @@ -139,6 +142,23 @@ app/ - ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning. - DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies. +### Server login route escalation + +`prepare_authenticated_contact_connection` (`routers/server_control.py`, shared by repeater and room login) sends one login over the contact's effective route. If that draws **no reply at all**, it calls `reset_path(...)` and retries exactly once as flood. + +This is intentionally *more* than the reference implementations do — do not "correct" it back to single-shot: +- Firmware `BaseChatMesh::sendLogin` picks flood only when `out_path_len == OUT_PATH_UNKNOWN` and never retries; `CMD_SEND_LOGIN` calls it once. +- `meshcore_py` has `send_msg_with_retry` (with `flood_after` + `reset_path`) but no login equivalent — `send_login`/`send_login_sync` are single-shot. +- Firmware only clears a stale path when the *host* asks (`CMD_RESET_PATH`); client-side path learning is otherwise passive via `onContactPathRecv`. + +Escalating is still correct because the **server** side treats an inbound flood as its cue to relearn the return path (`simple_repeater`/`simple_room_server`: `if (is_flood) client->out_path_len = OUT_PATH_UNKNOWN`). A flood login is therefore what repairs a broken route in both directions, and login gates the whole repeater dashboard. + +Escalation is bounded to one extra attempt and only fires when: +- the first attempt **timed out**. `LOGIN_FAILED` means the server heard us and refused, so the route is fine and retrying only hammers it with bad credentials; a send error is a local radio problem a different route will not fix. +- the contact was **not already on flood** (`effective_route_source != "flood"`), since the retry would otherwise be byte-identical. + +The retry deliberately does not re-run `_ensure_on_radio` — re-adding the contact would restore the route just cleared. `reset_path` clears the route on the radio only; the stored contact route is untouched, so the next `add_contact` re-stages it. That mirrors the DM retry and keeps one bad login from discarding a route that may be fine. + ### Echo/repeat dedup - Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`. @@ -149,9 +169,20 @@ app/ - `ROUTE_TYPE_TRANSPORT_FLOOD`/`ROUTE_TYPE_TRANSPORT_DIRECT` packets carry a 4-byte transport-code block; `parse_packet_envelope` exposes it as `transport_codes = (code_1, code_2)` (little-endian uint16s; `code_2` is reserved/0). - `code_1` is a keyed MAC over the payload, not a stable per-region id: `code = HMAC-SHA256(SHA256("#" + region_name)[:16], payload_type || payload)[:2]` (firmware `TransportKeyStore.cpp`; reserved values `0x0000`/`0xFFFF` are nudged to `0x0001`/`0xFFFE`). There is **no** reverse lookup table — to name a packet's region you recompute the code per candidate region and check for a match (`app/region_resolver.py`). -- Candidate region names come from `app_settings.known_regions` (user-editable, seeded by migration 064 from `flood_scope` + channel `flood_scope_override`). +- Candidate region names come from `app_settings.known_regions` (user-editable, seeded by migration 063 from `flood_scope` + channel `flood_scope_override`). - Channel messages persist `messages.transport_code` (uint16, NULL = unscoped plain flood) and `messages.region` (resolved name, NULL = scoped but no list match) at ingest, so the chat region badge survives raw-packet purge. The packet inspector (`GET /packets/{id}` and the `raw_packet` WS broadcast) resolves region on the fly against the current list since it still holds the raw payload. +### Region-scope adoption stats (`region_scope_24h`) + +`GET /statistics` reports regional flood-scope uptake as two views with different denominators that intentionally will not agree: + +- **Traffic** (`bucket_region_scope` in `path_utils.py`) counts flood-routed (`route_type` 0/1) GroupText packets across all channels, including undecryptable ones. Zero-hop/direct sends are excluded because firmware reaches them through the non-transport `sendZeroHop`/`sendDirect` overloads and they can never carry transport codes. +- **Senders** (`StatisticsRepository._region_scope_senders_24h`) counts distinct senders with at least one scoped message. Attribution requires decryption, so it only covers channels we hold keys for — narrower, but self-validating (a decrypted packet is provably not a corrupt capture) and immune to one chatty node skewing the result. Identity is `sender_key` falling back to `sender_name`; scoping reads `messages.transport_code`, falling back to the linked raw packet for rows stored before region tagging existed. + +`false_positive_floor` exists because corrupt RF captures land in `raw_packets` with effectively random headers and a share of them claim `TRANSPORT_FLOOD`. That garbage spreads near-uniformly across payload-type buckets, so it is measured directly from payload types the protocol does not define (`0x0C`/`0x0D`/`0x0E`) and averaged per bucket. **A `scoped_messages` count at or below the floor is not evidence of adoption**; surface the two together and never show the percentage alone. Do not "fix" the floor by removing it — without it the metric reads several times higher than reality. + +Both traffic buckets come from one 24h raw-packet scan (`_packet_shape_24h`) shared with `path_hash_width_24h`, so adding region stats costs no extra query or parse pass. + ### Raw packet dedup policy - Raw packet storage deduplicates by payload hash (`RawPacketRepository.create`), excluding routing/path bytes. @@ -228,19 +259,20 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu - `POST /contacts/{public_key}/routing-override` - `POST /contacts/{public_key}/trace` - `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort -- `POST /contacts/{public_key}/repeater/login` +- `POST /contacts/{public_key}/repeater/login` — one attempt on the effective route, then one flood retry on timeout - `POST /contacts/{public_key}/repeater/status` - `POST /contacts/{public_key}/repeater/lpp-telemetry` - `POST /contacts/{public_key}/repeater/neighbors` - `POST /contacts/{public_key}/repeater/acl` - `POST /contacts/{public_key}/repeater/node-info` - `POST /contacts/{public_key}/repeater/radio-settings` +- `POST /contacts/{public_key}/repeater/regions` — CLI region hierarchy, falling back to the guest anon flood-allowed names (`source`: `cli` or `anon`) - `POST /contacts/{public_key}/repeater/advert-intervals` - `POST /contacts/{public_key}/repeater/owner-info` - `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access) - `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`) - `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only) -- `POST /contacts/{public_key}/room/login` +- `POST /contacts/{public_key}/room/login` — one attempt on the effective route, then one flood retry on timeout - `POST /contacts/{public_key}/room/status` - `POST /contacts/{public_key}/room/lpp-telemetry` - `POST /contacts/{public_key}/room/acl` @@ -293,7 +325,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu - `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart ### Statistics -- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels) +- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels, `region_scope_24h` regional adoption) ### Push - `GET /push/vapid-public-key` — VAPID public key for browser `PushManager.subscribe()` diff --git a/app/models.py b/app/models.py index 263077e..6507682 100644 --- a/app/models.py +++ b/app/models.py @@ -1083,6 +1083,41 @@ class PacketsPerHourBucket(BaseModel): count: int = Field(description="Number of packets received in that hour") +class RegionScopeStats(BaseModel): + """Regional flood-scope adoption over the last 24 hours. + + Two independent views, deliberately not merged — they have different + denominators and will not agree: + + - Traffic (``total_messages``/``scoped_messages``) counts flood-routed + channel-message packets across all channels, including ones we cannot + decrypt. Broad coverage, but corrupt RF captures contribute false + positives, hence ``false_positive_floor``. + - Senders (``total_senders``/``scoped_senders``) counts distinct message + senders, which requires decryption and so only covers channels we hold + keys for. Narrower, but noise-free and immune to one chatty node skewing + the result. + """ + + total_messages: int = Field( + description="Flood-routed channel-message packets heard in the last 24h (unique payloads)" + ) + scoped_messages: int = Field(description="Of those, how many carried a regional transport code") + scoped_pct: float + false_positive_floor: float = Field( + description=( + "Estimated false positives in scoped_messages, measured from transport-routed " + "packets claiming a payload type the protocol does not define. A scoped_messages " + "value at or below this is not evidence of regional adoption." + ) + ) + total_senders: int = Field( + description="Distinct channel-message senders in the last 24h (decryptable channels only)" + ) + scoped_senders: int = Field(description="Of those, how many sent at least one scoped message") + scoped_senders_pct: float + + class StatisticsResponse(BaseModel): busiest_channels_24h: list[BusyChannel] contact_count: int @@ -1098,6 +1133,7 @@ class StatisticsResponse(BaseModel): repeaters_heard: ContactActivityCounts known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats + region_scope_24h: RegionScopeStats packets_per_hour_72h: list[PacketsPerHourBucket] noise_floor_24h: NoiseFloorHistoryStats diff --git a/app/path_utils.py b/app/path_utils.py index e20eb39..be643ca 100644 --- a/app/path_utils.py +++ b/app/path_utils.py @@ -304,6 +304,69 @@ def bucket_path_hash_widths(rows: Iterable) -> dict[str, int | float]: } +# Payload types with no meaning in the MeshCore protocol. Any packet claiming one +# is corrupt by definition, which makes them a usable gauge for how much RF garbage +# is sitting in a given route-type bucket. See bucket_region_scope(). +UNDEFINED_PAYLOAD_TYPES = (0x0C, 0x0D, 0x0E) + +_PAYLOAD_TYPE_GROUP_TEXT = 0x05 +_FLOOD_ROUTE_TYPES = (0x00, 0x01) # TRANSPORT_FLOOD, FLOOD + + +def bucket_region_scope(rows: Iterable) -> dict[str, int | float]: + """Count flood-routed channel messages carrying a regional transport code. + + *rows* must be an already-fetched list whose elements have a ``data`` column + containing raw packet bytes. + + Only flood-routed packets are counted. Zero-hop and direct sends can never + carry transport codes (firmware reaches them through the non-transport + ``sendZeroHop``/``sendDirect`` overloads), so including them would silently + dilute the percentage. + + ``false_positive_floor`` exists because corrupt RF captures still land in + ``raw_packets`` with effectively random header bytes, and a share of them + claim TRANSPORT_FLOOD. That garbage spreads near-uniformly across payload-type + buckets, so we measure it directly: count transport-routed packets claiming a + payload type the protocol does not define, and average per bucket. A + ``scoped_messages`` count at or below ``false_positive_floor`` is not evidence + of regional adoption, and callers should present the two together. + """ + total = 0 + scoped = 0 + undefined_scoped = 0 + + for row in rows: + envelope = parse_packet_envelope(bytes(row["data"])) + if envelope is None: + continue + + if ( + envelope.route_type == 0x00 + and envelope.payload_type in UNDEFINED_PAYLOAD_TYPES + and envelope.transport_codes is not None + ): + undefined_scoped += 1 + continue + + if envelope.payload_type != _PAYLOAD_TYPE_GROUP_TEXT: + continue + if envelope.route_type not in _FLOOD_ROUTE_TYPES: + continue + + total += 1 + if envelope.transport_codes is not None: + scoped += 1 + + noise_floor = undefined_scoped / len(UNDEFINED_PAYLOAD_TYPES) + return { + "total_messages": total, + "scoped_messages": scoped, + "scoped_pct": (scoped / total) * 100 if total else 0.0, + "false_positive_floor": noise_floor, + } + + def calculate_packet_hash(raw_bytes: bytes) -> str: """Calculate packet hash matching MeshCore's Packet::calculatePacketHash(). diff --git a/app/repository/settings.py b/app/repository/settings.py index 3cd6681..884faa8 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -7,7 +7,7 @@ import aiosqlite from app.database import db from app.models import AppSettings -from app.path_utils import bucket_path_hash_widths +from app.path_utils import bucket_path_hash_widths, bucket_region_scope, parse_packet_envelope from app.telemetry_interval import DEFAULT_TELEMETRY_INTERVAL_HOURS logger = logging.getLogger(__name__) @@ -513,8 +513,12 @@ class StatisticsRepository: return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows] @staticmethod - async def _path_hash_width_24h() -> dict[str, int | float]: - """Count parsed raw packets from the last 24h by hop hash width.""" + async def _packet_shape_24h() -> tuple[dict[str, int | float], dict[str, int | float]]: + """Bucket the last 24h of raw packets by hop hash width and region scope. + + Both buckets come from one fetch so the snapshot only scans the packet + table once. Returns ``(path_hash_width, region_scope)``. + """ now = int(time.time()) async with db.readonly() as conn: async with conn.execute( @@ -522,7 +526,61 @@ class StatisticsRepository: (now - SECONDS_24H,), ) as cursor: rows = await cursor.fetchall() - return bucket_path_hash_widths(rows) + return bucket_path_hash_widths(rows), bucket_region_scope(rows) + + @staticmethod + async def _region_scope_senders_24h() -> dict[str, int | float]: + """Count distinct channel-message senders who scoped at least one send. + + Sender attribution requires having decrypted the message, so this only + covers channels we hold keys for — a narrower population than the + packet-level count, but a self-validating one: a message we decrypted is + provably not a corrupt capture, so this number needs no noise floor. + + Scoping is read from ``messages.transport_code`` where present, falling + back to the linked raw packet for rows stored before region tagging + existed. Senders are keyed by ``sender_key`` where resolved, falling back + to ``sender_name`` — one physical operator may run several nodes, hence + "senders" rather than "users". + """ + now = int(time.time()) + async with db.readonly() as conn: + async with conn.execute( + """ + SELECT m.id, m.sender_key, m.sender_name, m.transport_code, p.data + FROM messages m + LEFT JOIN raw_packets p ON p.message_id = m.id + WHERE m.type = 'CHAN' AND m.outgoing = 0 AND m.received_at >= ? + """, + (now - SECONDS_24H,), + ) as cursor: + rows = await cursor.fetchall() + + senders: set[str] = set() + scoped_senders: set[str] = set() + for row in rows: + identity = row["sender_key"] or row["sender_name"] + if not identity: + continue + senders.add(identity) + + if row["transport_code"] is not None: + scoped_senders.add(identity) + continue + raw = row["data"] + if raw is None: + continue + envelope = parse_packet_envelope(bytes(raw)) + if envelope is not None and envelope.transport_codes is not None: + scoped_senders.add(identity) + + total = len(senders) + scoped = len(scoped_senders) + return { + "total_senders": total, + "scoped_senders": scoped, + "scoped_senders_pct": (scoped / total) * 100 if total else 0.0, + } @staticmethod async def get_all() -> dict: @@ -600,7 +658,8 @@ class StatisticsRepository: contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True) repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2) known_channels_active = await StatisticsRepository._known_channels_active() - path_hash_width_24h = await StatisticsRepository._path_hash_width_24h() + path_hash_width_24h, region_scope = await StatisticsRepository._packet_shape_24h() + region_scope.update(await StatisticsRepository._region_scope_senders_24h()) packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h() return { @@ -618,5 +677,6 @@ class StatisticsRepository: "repeaters_heard": repeaters_heard, "known_channels_active": known_channels_active, "path_hash_width_24h": path_hash_width_24h, + "region_scope_24h": region_scope, "packets_per_hour_72h": packets_per_hour_72h, } diff --git a/app/routers/server_control.py b/app/routers/server_control.py index 831da18..2189d9b 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -80,6 +80,15 @@ def _login_timeout_message(label: str) -> str: ) +def _login_flood_retry_timeout_message(label: str) -> str: + return ( + f"No login confirmation was heard from the {label}, including a retry sent as flood " + "in case the stored route was stale. That can mean the password was wrong, the " + f"{label} is out of range, or the reply was missed in transit. " + "You're free to attempt interaction; try logging in again if authenticated actions fail." + ) + + def extract_response_text(event) -> str: """Extract text from a CLI response event, stripping the firmware '> ' prefix.""" text = event.payload.get("text", str(event.payload)) @@ -214,17 +223,20 @@ async def fetch_contact_cli_response( subscription.unsubscribe() -async def prepare_authenticated_contact_connection( +async def _attempt_server_login( mc, contact: Contact, password: str, *, - label: str | None = None, - response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS, + contact_label: str, + response_timeout: float, ) -> RepeaterLoginResponse: - """Prepare connection to a server-capable contact by adding it to the radio and logging in.""" + """Send one login and wait for the reply. + + Subscriptions are per-attempt because the resolving future can only be + settled once — a retry needs a fresh pair. + """ pubkey_prefix = contact.public_key[:12].lower() - contact_label = label or get_server_contact_label(contact) loop = asyncio.get_running_loop() login_future = loop.create_future() @@ -254,9 +266,6 @@ async def prepare_authenticated_contact_connection( ) try: - logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12]) - await _ensure_on_radio(mc, contact) - logger.info("Sending login to %s %s", contact_label, contact.public_key[:12]) login_result = await mc.commands.send_login(contact.public_key, password) @@ -268,10 +277,7 @@ async def prepare_authenticated_contact_connection( ) try: - return await asyncio.wait_for( - login_future, - timeout=response_timeout, - ) + return await asyncio.wait_for(login_future, timeout=response_timeout) except TimeoutError: logger.warning( "No login response from %s %s within %.1fs", @@ -284,6 +290,105 @@ async def prepare_authenticated_contact_connection( authenticated=False, message=_login_timeout_message(contact_label), ) + finally: + success_subscription.unsubscribe() + failed_subscription.unsubscribe() + + +async def prepare_authenticated_contact_connection( + mc, + contact: Contact, + password: str, + *, + label: str | None = None, + response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS, +) -> RepeaterLoginResponse: + """Prepare connection to a server-capable contact by adding it to the radio and logging in. + + A login that draws no reply at all may mean the stored direct route has gone + stale, so it escalates to one flood retry — mirroring the DM send path, which + already resets the path before its final attempt. This is deliberately more + than the reference clients do: firmware ``sendLogin`` and ``send_login_sync`` + are both single-shot, and firmware only clears a stale path when the *host* + asks it to (``CMD_RESET_PATH``). Escalating is still the right call because + the server side treats an inbound flood as its cue to relearn the return path + (``simple_repeater``/``simple_room_server``: ``if (is_flood) + client->out_path_len = OUT_PATH_UNKNOWN``), so a flood login is exactly what + repairs a broken route in both directions. + + Escalation is bounded to a single extra attempt and only fires when: + - the first attempt timed out. An explicit ``LOGIN_FAILED`` means the + server heard us and refused, so the route is fine and retrying would + just hammer it with bad credentials. A send error is a local radio + problem that a different route will not fix. + - the first attempt actually used a route. If the contact was already on + flood, the retry would be byte-identical for no gain. + + ``reset_path`` clears the route on the radio only; the stored contact route + is left alone, so the next ``add_contact`` re-stages it. That matches the DM + retry and keeps a single bad login from discarding a route that may be fine. + """ + contact_label = label or get_server_contact_label(contact) + + try: + logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12]) + await _ensure_on_radio(mc, contact) + + response = await _attempt_server_login( + mc, + contact, + password, + contact_label=contact_label, + response_timeout=response_timeout, + ) + if response.status != "timeout": + return response + + if contact.effective_route_source == "flood": + logger.debug( + "Login to %s %s timed out on flood; no route to escalate from", + contact_label, + contact.public_key[:12], + ) + return response + + logger.info( + "Login to %s %s timed out on the %s route; resetting path and retrying as flood", + contact_label, + contact.public_key[:12], + contact.effective_route_source, + ) + reset_result = await mc.commands.reset_path(contact.public_key) + if reset_result is None: + logger.warning( + "No response from radio for reset_path to %s before flood login retry", + contact.public_key[:12], + ) + return response + if reset_result.type == EventType.ERROR: + logger.warning( + "Failed to reset path before flood login retry to %s: %s", + contact.public_key[:12], + reset_result.payload, + ) + return response + + # Deliberately no _ensure_on_radio here — re-adding the contact would + # restore the very route we just cleared, and the retry would go direct. + flood_response = await _attempt_server_login( + mc, + contact, + password, + contact_label=contact_label, + response_timeout=response_timeout, + ) + if flood_response.status == "timeout": + return RepeaterLoginResponse( + status="timeout", + authenticated=False, + message=_login_flood_retry_timeout_message(contact_label), + ) + return flood_response except HTTPException as exc: logger.warning( "%s login setup failed for %s: %s", @@ -296,9 +401,6 @@ async def prepare_authenticated_contact_connection( authenticated=False, message=f"{_login_send_failed_message(contact_label)} ({exc.detail})", ) - finally: - success_subscription.unsubscribe() - failed_subscription.unsubscribe() async def batch_cli_fetch( diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9231f9b..7ba86bc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -41,9 +41,13 @@ frontend/src/ ├── themes.css # Color theme definitions ├── contexts/ │ ├── DistanceUnitContext.tsx # Browser-local distance-unit context/provider +│ ├── PathHopWidthContext.tsx # Browser-local path hop-width display preference +│ ├── RichPayloadContext.tsx # Browser-local rich MeshCore payload rendering preference │ └── PushSubscriptionContext.tsx # Push subscription state context/provider ├── lib/ │ └── utils.ts # cn() — clsx + tailwind-merge helper +├── networkGraph/ +│ └── packetNetworkGraph.ts # Packet→network graph construction shared by visualizer surfaces ├── stores/ │ └── rawPacketStore.ts # Overheard packet stream + session stats, outside React ├── hooks/ @@ -62,6 +66,7 @@ frontend/src/ │ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch │ ├── usePushSubscription.ts # Web Push subscription lifecycle, per-conversation filters │ ├── useFaviconBadge.ts # Browser tab unread badge state +│ ├── useEntranceSettled.ts # Defers entrance animation work until layout settles │ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence ├── components/ │ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning @@ -83,6 +88,10 @@ frontend/src/ │ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers │ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers │ ├── regionScope.ts # Regional flood-scope label/normalization helpers +│ ├── meshcoreOpenPayloads.ts # Rich MeshCore Open payload detection/rendering helpers +│ ├── textReplace.ts # Shared message text substitution helpers +│ ├── pathHopWidthPreference.ts # LocalStorage persistence for hop-width display toggle +│ ├── richPayloadPreference.ts # LocalStorage persistence for rich payload rendering toggle │ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles │ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options │ ├── a11y.ts # Keyboard accessibility helper @@ -144,7 +153,7 @@ frontend/src/ │ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD │ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists │ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt -│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats +│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats (incl. region-scope adoption) │ │ ├── SettingsAboutSection.tsx # Version, author, license, links │ │ ├── ThemeSelector.tsx # Color theme picker │ │ └── BulkDeleteContactsModal.tsx # Bulk contact deletion dialog @@ -155,6 +164,7 @@ frontend/src/ │ │ ├── RepeaterAclPane.tsx # Permission table │ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift │ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals +│ │ ├── RepeaterRegionsPane.tsx # Region hierarchy / flood-allowed region names │ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data │ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password │ │ ├── RepeaterTelemetryHistoryPane.tsx # Historical telemetry chart/table @@ -417,9 +427,9 @@ State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput). -**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. +**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. The frontend sends exactly one request; the backend internally escalates a timed-out login to one flood retry (see `app/AGENTS.md` § "Server login route escalation"), so a single call may take up to two response windows. Do not add a client-side login retry loop on top — a `LOGIN_FAILED` result means the password was refused, not that the route needs another attempt. -**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock). +**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock). **Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`. @@ -474,6 +484,15 @@ Key conventions documented in the reference: - **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active). - **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`. +### Region-scope adoption panel + +`SettingsStatisticsSection.tsx` renders `stats.region_scope_24h` via `RegionScopeStatsPanel`. Two presentation rules exist because regional adoption is currently very sparse, and both are deliberate: + +- **Fractions, not bare percentages.** "3 of 117" carries the sample size that "2.6%" hides. +- **The traffic percentage is withheld** when the scoped count is at or below `false_positive_floor` (corrupt-capture noise) or when the share would round to `0.0%`. The floor caveat is always shown alongside a non-zero scoped count. The sender figure is never suppressed — it requires successful decryption and so carries no noise. + +Traffic and sender figures use different denominators (all channels vs. decryptable-only) and are not expected to match. + ## Security Posture (intentional) - No authentication UI. diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 76b35ae..69a2e13 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -13,12 +13,76 @@ import { } from 'recharts'; import { Separator } from '../ui/separator'; import { api } from '../../api'; -import type { StatisticsResponse } from '../../types'; +import type { RegionScopeStats, StatisticsResponse } from '../../types'; function formatPercent(value: number): string { return `${value.toFixed(1)}%`; } +/** + * Regional flood-scope adoption. Deliberately shows fractions rather than bare + * percentages: with adoption this sparse, "3 of 117" communicates the sample + * size that "2.6%" hides. + */ +function RegionScopeStatsPanel({ stats }: { stats: RegionScopeStats }) { + // Corrupt RF captures land in the packet table with random headers, some of + // which claim to be region-scoped. At or below the measured floor there is + // nothing to report but noise, so withhold the percentage and say so. + const floor = stats.false_positive_floor; + const withinNoise = stats.scoped_messages <= floor; + // Real-world scoping is currently rare enough that the share rounds to "0.0%", + // which reads as a broken widget. Below that resolution the fraction alone is + // the honest presentation. + const showTrafficPct = stats.total_messages > 0 && !withinNoise && stats.scoped_pct >= 0.05; + + return ( +
+ How much local traffic uses regional flood scoping. Traffic covers all channel messages + heard, including channels you have no key for; senders only counts channels you can decrypt, + so the two use different denominators and will not match. +
++ {withinNoise + ? `Scoped message count is at or below the estimated false-positive floor (${floor.toFixed(0)}) from corrupt packet captures, so it is not evidence of regional adoption.` + : `Includes an estimated ${floor.toFixed(0)} false positives from corrupt packet captures.`}{' '} + The sender count is unaffected — it requires successful decryption. +
+ )} + {stats.total_messages === 0 && ( ++ No channel messages heard in the last 24 hours. +
+ )} +