Merge branch 'main' into pr-339

# Conflicts:
#	frontend/AGENTS.md
This commit is contained in:
Jack Kingsman
2026-07-25 15:19:51 -07:00
12 changed files with 940 additions and 34 deletions
+5 -3
View File
@@ -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. |
+37 -5
View File
@@ -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()`
+36
View File
@@ -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
+63
View File
@@ -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().
+65 -5
View File
@@ -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,
}
+117 -15
View File
@@ -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(
+22 -3
View File
@@ -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.
@@ -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 (
<div>
<h3 className="text-base font-semibold tracking-tight mb-2">Region Scope (24h)</h3>
<p className="text-[0.8125rem] text-muted-foreground mb-3">
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.
</p>
<div className="space-y-2">
<div className="flex justify-between items-center gap-4">
<span className="text-sm text-muted-foreground">Scoped messages</span>
<span className="font-medium text-right">
{stats.scoped_messages.toLocaleString()} of {stats.total_messages.toLocaleString()}
{showTrafficPct && (
<span className="text-muted-foreground"> ({formatPercent(stats.scoped_pct)})</span>
)}
</span>
</div>
<div className="flex justify-between items-center gap-4">
<span className="text-sm text-muted-foreground">Senders using regions</span>
<span className="font-medium text-right">
{stats.scoped_senders.toLocaleString()} of {stats.total_senders.toLocaleString()}
{stats.total_senders > 0 && (
<span className="text-muted-foreground">
{' '}
({formatPercent(stats.scoped_senders_pct)})
</span>
)}
</span>
</div>
</div>
{floor > 0 && stats.scoped_messages > 0 && (
<p className="text-[0.8125rem] text-muted-foreground mt-2">
{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.
</p>
)}
{stats.total_messages === 0 && (
<p className="text-sm text-muted-foreground mt-2">
No channel messages heard in the last 24 hours.
</p>
)}
</div>
);
}
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
const TOOLTIP_STYLE = {
@@ -404,6 +468,11 @@ export function SettingsStatisticsSection({ className }: { className?: string })
)}
</div>
<Separator />
{/* Region Scope */}
<RegionScopeStatsPanel stats={stats.region_scope_24h} />
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
+158
View File
@@ -777,6 +777,15 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
region_scope_24h: {
total_messages: 120,
scoped_messages: 40,
scoped_pct: 33.3,
false_positive_floor: 2,
total_senders: 12,
scoped_senders: 3,
scoped_senders_pct: 25,
},
packets_per_hour_72h: [
{ timestamp: 1711792800, count: 12 },
{ timestamp: 1711796400, count: 8 },
@@ -824,6 +833,146 @@ describe('SettingsModal', () => {
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
// Fractions, not bare percentages — the sample size matters at this sparsity
expect(screen.getByText(/40 of 120/)).toBeInTheDocument();
expect(screen.getByText(/3 of 12/)).toBeInTheDocument();
// 40 scoped is well above the floor of 2, so the percentage is shown
expect(screen.getByText(/33\.3%/)).toBeInTheDocument();
expect(
screen.queryByText(/at or below the estimated false-positive floor/)
).not.toBeInTheDocument();
});
it('discloses the false-positive floor and withholds a sub-0.1% scoped share', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
contact_count: 0,
repeater_count: 0,
channel_count: 0,
total_packets: 0,
decrypted_packets: 0,
undecrypted_packets: 0,
total_dms: 0,
total_channel_messages: 0,
total_outgoing: 0,
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
path_hash_width_24h: {
total_packets: 0,
single_byte: 0,
double_byte: 0,
triple_byte: 0,
single_byte_pct: 0,
double_byte_pct: 0,
triple_byte_pct: 0,
},
// Mirrors real-world data: 70 "scoped" packets against a measured floor of
// 60 is corrupt-capture noise, not adoption.
region_scope_24h: {
total_messages: 391757,
scoped_messages: 70,
scoped_pct: 0.0179,
false_positive_floor: 60.3,
total_senders: 117,
scoped_senders: 3,
scoped_senders_pct: 2.56,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockStats), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
await waitFor(() => {
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
});
// 70 scoped sits just above the 60.3 floor, so most of it is corrupt captures
expect(screen.getByText(/Includes an estimated 60 false positives/)).toBeInTheDocument();
// 0.0179% would render as a meaningless "0.0%", so the share is withheld
expect(screen.queryByText(/0\.0%/)).not.toBeInTheDocument();
expect(screen.getByText(/70 of 391,757/)).toBeInTheDocument();
// ...but the decryption-backed sender figure still stands
expect(screen.getByText(/3 of 117/)).toBeInTheDocument();
expect(screen.getByText(/2\.6%/)).toBeInTheDocument();
});
it('reports scoped traffic as noise when it is at or below the floor', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
contact_count: 0,
repeater_count: 0,
channel_count: 0,
total_packets: 0,
decrypted_packets: 0,
undecrypted_packets: 0,
total_dms: 0,
total_channel_messages: 0,
total_outgoing: 0,
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
path_hash_width_24h: {
total_packets: 0,
single_byte: 0,
double_byte: 0,
triple_byte: 0,
single_byte_pct: 0,
double_byte_pct: 0,
triple_byte_pct: 0,
},
region_scope_24h: {
total_messages: 5000,
scoped_messages: 12,
scoped_pct: 0.24,
false_positive_floor: 20,
total_senders: 40,
scoped_senders: 0,
scoped_senders_pct: 0,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockStats), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
await waitFor(() => {
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
});
expect(
screen.getByText(/at or below the estimated false-positive floor \(20\)/)
).toBeInTheDocument();
// Percentage withheld even though 0.24% would round visibly — it is noise
expect(screen.queryByText(/0\.2%/)).not.toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -850,6 +999,15 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
region_scope_24h: {
total_messages: 0,
scoped_messages: 0,
scoped_pct: 0,
false_positive_floor: 0,
total_senders: 0,
scoped_senders: 0,
scoped_senders_pct: 0,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
+19
View File
@@ -674,6 +674,24 @@ interface PacketsPerHourBucket {
count: number;
}
/**
* Regional flood-scope adoption over the last 24h. Two views with different
* denominators that will not agree traffic spans all channels including
* undecryptable ones (so it carries a false-positive floor from corrupt RF
* captures), while senders requires decryption and is therefore noise-free but
* limited to channels we hold keys for.
*/
export interface RegionScopeStats {
total_messages: number;
scoped_messages: number;
scoped_pct: number;
/** Estimated false positives in scoped_messages. At or below this = not adoption. */
false_positive_floor: number;
total_senders: number;
scoped_senders: number;
scoped_senders_pct: number;
}
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -697,6 +715,7 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
region_scope_24h: RegionScopeStats;
packets_per_hour_72h: PacketsPerHourBucket[];
noise_floor_24h: NoiseFloorHistoryStats;
}
+156
View File
@@ -818,6 +818,162 @@ class TestPrepareRepeaterConnection:
assert "No login confirmation was heard from the repeater" in (response.message or "")
def _make_routed_contact() -> Contact:
"""A repeater with a learned direct route, so a login goes out direct."""
contact = Contact(
public_key=KEY_A,
name="Repeater",
type=2,
direct_path="aabb",
direct_path_len=2,
direct_path_hash_mode=0,
)
assert contact.effective_route_source == "direct"
return contact
class TestRepeaterLoginFloodEscalation:
"""A login that draws no reply escalates to one flood retry.
Firmware's own ``sendLogin`` is single-shot, so this is RT going beyond the
reference clients. It works because the *server* treats an inbound flood as
its cue to relearn the return path.
"""
@pytest.mark.asyncio
async def test_timeout_on_direct_route_resets_path_and_retries_as_flood(self):
mc = _mock_mc()
contact = _make_routed_contact()
subscriptions: dict[EventType, tuple[object, object]] = {}
attempts = 0
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
nonlocal attempts
attempts += 1
# First attempt (direct) is never answered; the flood retry lands.
if attempts > 1:
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": KEY_A[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "ok"
assert response.authenticated is True
assert attempts == 2
mc.commands.reset_path.assert_awaited_once_with(KEY_A)
# The contact must not be re-added between attempts — that would restore
# the route we just cleared and send the retry direct again.
assert mc.commands.add_contact.await_count == 1
@pytest.mark.asyncio
async def test_flood_retry_timeout_reports_that_flood_was_tried(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
assert response.authenticated is False
assert "retry sent as flood" in (response.message or "")
assert mc.commands.send_login.await_count == 2
@pytest.mark.asyncio
async def test_no_escalation_when_contact_is_already_flood(self):
mc = _mock_mc()
contact = _make_contact() # no direct route -> already flooding
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
# A flood retry would be byte-identical, so don't bother the repeater
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejected_login_does_not_escalate(self):
"""LOGIN_FAILED means the route works and the password doesn't."""
mc = _mock_mc()
contact = _make_routed_contact()
subscriptions: dict[EventType, tuple[object, object]] = {}
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
callback, _filters = subscriptions[EventType.LOGIN_FAILED]
callback(_radio_result(EventType.LOGIN_FAILED, {"pubkey_prefix": KEY_A[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
response = await prepare_repeater_connection(mc, contact, "bad")
assert response.status == "error"
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_error_does_not_escalate(self):
"""A local radio failure is not fixed by picking a different route."""
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.send_login = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"err": "busy"})
)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "error"
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_failed_reset_path_returns_original_timeout(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"err": "nope"})
)
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
# Without a cleared path the retry would just go direct again
assert mc.commands.send_login.await_count == 1
assert "retry sent as flood" not in (response.message or "")
@pytest.mark.asyncio
async def test_no_reset_path_response_returns_original_timeout(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(return_value=None)
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
assert mc.commands.send_login.await_count == 1
class TestRepeaterStatus:
@pytest.mark.asyncio
async def test_success_with_field_mapping(self, test_db):
+192 -2
View File
@@ -43,6 +43,15 @@ class TestStatisticsEmpty:
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
assert result["region_scope_24h"] == {
"total_messages": 0,
"scoped_messages": 0,
"scoped_pct": 0.0,
"false_positive_floor": 0.0,
"total_senders": 0,
"scoped_senders": 0,
"scoped_senders_pct": 0.0,
}
assert result["packets_per_hour_72h"] == []
@@ -378,10 +387,18 @@ class TestPathHashWidthStats:
hash_size = hash_sizes.get(raw_packet)
if hash_size is None:
return None
return SimpleNamespace(hash_size=hash_size)
# The scan is shared with the region-scope bucketer, so the stub has
# to carry the envelope fields that one reads too. Plain flood
# GroupText keeps it out of the region counters' way.
return SimpleNamespace(
hash_size=hash_size,
route_type=0x01,
payload_type=0x05,
transport_codes=None,
)
with patch("app.path_utils.parse_packet_envelope", side_effect=fake_parse):
breakdown = await StatisticsRepository._path_hash_width_24h()
breakdown, _region_scope = await StatisticsRepository._packet_shape_24h()
assert breakdown["total_packets"] == 3
assert breakdown["single_byte"] == 1
@@ -389,6 +406,179 @@ class TestPathHashWidthStats:
assert breakdown["triple_byte"] == 1
class TestRegionScopeStats:
"""Regional flood-scope adoption counters.
Packet fixtures are built by hand so the header bits are explicit:
header = (payload_type << 2) | route_type, then a 4-byte transport-code
block for TRANSPORT_* routes, then the packed path byte, then payload.
"""
# GROUP_TEXT (0x05) flood (0x01) -> header 0x15; path byte 0x00 = 0 hops, 1-byte hashes
UNSCOPED_GROUP_TEXT = bytes.fromhex("1500AA")
# GROUP_TEXT (0x05) transport-flood (0x00) -> header 0x14, + codes AABB/0000
SCOPED_GROUP_TEXT = bytes.fromhex("14AABB000000AA")
# Undefined payload type 0x0C, transport-flood -> header 0x30. Corrupt by
# definition, so it feeds the false-positive floor rather than the counts.
SCOPED_UNDEFINED_TYPE = bytes.fromhex("30AABB000000AA")
# GROUP_TEXT direct (0x02) -> header 0x16. Direct sends can never be scoped.
DIRECT_GROUP_TEXT = bytes.fromhex("1600AA")
async def _insert_packet(self, conn, data: bytes, tag: bytes, timestamp: int):
await conn.execute(
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(timestamp, data, tag * 32),
)
@pytest.mark.asyncio
async def test_counts_scoped_flood_group_text_only(self, test_db):
"""Only flood-routed GroupText packets count; direct sends are excluded."""
now = int(time.time())
conn = test_db.conn
await self._insert_packet(conn, self.SCOPED_GROUP_TEXT, b"\x11", now)
await self._insert_packet(conn, self.UNSCOPED_GROUP_TEXT, b"\x22", now)
await self._insert_packet(conn, self.DIRECT_GROUP_TEXT, b"\x33", now)
# Outside the 24h window
await self._insert_packet(conn, self.SCOPED_GROUP_TEXT, b"\x44", now - 90000)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
# Direct + stale packets excluded, so 2 in the denominator
assert stats["total_messages"] == 2
assert stats["scoped_messages"] == 1
assert stats["scoped_pct"] == pytest.approx(50.0)
@pytest.mark.asyncio
async def test_undefined_payload_types_feed_false_positive_floor(self, test_db):
"""Corrupt packets claiming an undefined type estimate the noise floor."""
now = int(time.time())
conn = test_db.conn
await self._insert_packet(conn, self.UNSCOPED_GROUP_TEXT, b"\x11", now)
# Three corrupt transport-routed packets across the undefined-type buckets
for i, header in enumerate(("30", "34", "38")): # types 0x0C, 0x0D, 0x0E
await self._insert_packet(
conn, bytes.fromhex(f"{header}AABB000000AA"), bytes([0x20 + i]), now
)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
# Garbage must not inflate the real counts...
assert stats["total_messages"] == 1
assert stats["scoped_messages"] == 0
# ...but should surface as the floor: 3 packets over 3 undefined buckets
assert stats["false_positive_floor"] == pytest.approx(1.0)
@pytest.mark.asyncio
async def test_counts_distinct_senders_who_scoped(self, test_db):
"""Sender adoption is per distinct sender, not per message."""
now = int(time.time())
conn = test_db.conn
# One chatty scoping sender, one quiet scoping sender, one unscoped sender.
# Traffic share would read 4/5; sender share should read 2/3.
rows = [
("alice_key", "Alice", 0xAABB),
("alice_key", "Alice", 0xAABB),
("alice_key", "Alice", 0xAABB),
("bob_key", "Bob", 0xCCDD),
("carol_key", "Carol", None),
]
for i, (sender_key, sender_name, code) in enumerate(rows):
await conn.execute(
"""INSERT INTO messages
(type, conversation_key, text, received_at, outgoing,
sender_key, sender_name, transport_code)
VALUES ('CHAN', ?, ?, ?, 0, ?, ?, ?)""",
("EE" * 16, f"msg{i}", now, sender_key, sender_name, code),
)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
assert stats["total_senders"] == 3
assert stats["scoped_senders"] == 2
assert stats["scoped_senders_pct"] == pytest.approx(200 / 3, rel=1e-3)
@pytest.mark.asyncio
async def test_sender_scoping_falls_back_to_linked_raw_packet(self, test_db):
"""Rows predating region tagging are resolved via their retained packet."""
now = int(time.time())
conn = test_db.conn
async with conn.execute(
"""INSERT INTO messages
(type, conversation_key, text, received_at, outgoing, sender_key, sender_name)
VALUES ('CHAN', ?, 'legacy', ?, 0, 'dave_key', 'Dave')""",
("EE" * 16, now),
) as cursor:
message_id = cursor.lastrowid
# transport_code is NULL, but the linked packet still shows the scoping
await conn.execute(
"""INSERT INTO raw_packets (timestamp, data, payload_hash, message_id)
VALUES (?, ?, ?, ?)""",
(now, self.SCOPED_GROUP_TEXT, b"\x99" * 32, message_id),
)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
assert stats["total_senders"] == 1
assert stats["scoped_senders"] == 1
@pytest.mark.asyncio
async def test_senders_fall_back_to_name_without_resolved_key(self, test_db):
"""sender_key is only ~69% resolved, so name is the fallback identity."""
now = int(time.time())
conn = test_db.conn
for i, code in enumerate((0xAABB, None)):
await conn.execute(
"""INSERT INTO messages
(type, conversation_key, text, received_at, outgoing,
sender_key, sender_name, transport_code)
VALUES ('CHAN', ?, ?, ?, 0, NULL, ?, ?)""",
("EE" * 16, f"msg{i}", now, f"NamedOnly{i}", code),
)
# No identity at all -> not counted in either side of the fraction
await conn.execute(
"""INSERT INTO messages
(type, conversation_key, text, received_at, outgoing, sender_key, sender_name)
VALUES ('CHAN', ?, 'anon', ?, 0, NULL, NULL)""",
("EE" * 16, now),
)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
assert stats["total_senders"] == 2
assert stats["scoped_senders"] == 1
@pytest.mark.asyncio
async def test_outgoing_messages_excluded_from_sender_counts(self, test_db):
"""Our own sends are not evidence of anyone else's adoption."""
now = int(time.time())
conn = test_db.conn
await conn.execute(
"""INSERT INTO messages
(type, conversation_key, text, received_at, outgoing,
sender_key, sender_name, transport_code)
VALUES ('CHAN', ?, 'ours', ?, 1, 'me_key', 'Me', ?)""",
("EE" * 16, now, 0xAABB),
)
await conn.commit()
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
assert stats["total_senders"] == 0
assert stats["scoped_senders"] == 0
assert stats["scoped_senders_pct"] == 0.0
class TestPacketsPerHour:
@pytest.mark.asyncio
async def test_buckets_packets_by_hour(self, test_db):