mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
5 Commits
3.6.0
...
better-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b98198c60 | ||
|
|
29a76cef96 | ||
|
|
0768b59bcc | ||
|
|
2c6ab31202 | ||
|
|
7895671309 |
17
AGENTS.md
17
AGENTS.md
@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
|
||||
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
|
||||
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
|
||||
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
|
||||
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
|
||||
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
@@ -159,12 +163,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
|
||||
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
|
||||
- Non-final attempts use the effective route (`override > direct > flood`).
|
||||
- Retry timing follows the radio's `suggested_timeout`.
|
||||
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
|
||||
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
|
||||
|
||||
### ACK and Repeat Detection
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
|
||||
|
||||
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
|
||||
|
||||
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||
|
||||
This message-layer echo/path handling is independent of raw-packet storage deduplication.
|
||||
|
||||
@@ -104,8 +104,10 @@ app/
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
|
||||
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
|
||||
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
|
||||
### Read/unread state
|
||||
@@ -120,8 +122,10 @@ app/
|
||||
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
|
||||
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
|
||||
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
|
||||
- The final DM retry is intentionally sent as flood via `reset_path(...)`, even when a routing override exists.
|
||||
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
|
||||
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
|
||||
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
|
||||
- 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.
|
||||
|
||||
### Echo/repeat dedup
|
||||
|
||||
@@ -258,7 +262,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
@@ -267,6 +271,13 @@ Main tables:
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
Contact route state is canonicalized on the backend:
|
||||
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
|
||||
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
|
||||
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
|
||||
|
||||
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
|
||||
|
||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||
|
||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||
|
||||
@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT
|
||||
flood_scope_override TEXT,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
|
||||
@@ -60,6 +60,19 @@ class DecryptedDirectMessage:
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedPathPayload:
|
||||
"""Result of decrypting a PATH payload."""
|
||||
|
||||
dest_hash: str
|
||||
src_hash: str
|
||||
returned_path: bytes
|
||||
returned_path_len: int
|
||||
returned_path_hash_mode: int
|
||||
extra_type: int
|
||||
extra: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedAdvertisement:
|
||||
"""Result of parsing an advertisement packet."""
|
||||
@@ -563,3 +576,88 @@ def try_decrypt_dm(
|
||||
return None
|
||||
|
||||
return decrypt_direct_message(packet_info.payload, shared_secret)
|
||||
|
||||
|
||||
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
|
||||
"""Decrypt a PATH payload using the ECDH shared secret."""
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(payload[0], "02x")
|
||||
src_hash = format(payload[1], "02x")
|
||||
mac = payload[2:4]
|
||||
ciphertext = payload[4:]
|
||||
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
return None
|
||||
|
||||
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
if calculated_mac != mac:
|
||||
return None
|
||||
|
||||
try:
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
except Exception as e:
|
||||
logger.debug("AES decryption failed for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
if len(decrypted) < 2:
|
||||
return None
|
||||
|
||||
from app.path_utils import decode_path_byte
|
||||
|
||||
packed_len = decrypted[0]
|
||||
try:
|
||||
returned_path_len, hash_size = decode_path_byte(packed_len)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
path_byte_len = returned_path_len * hash_size
|
||||
if len(decrypted) < 1 + path_byte_len + 1:
|
||||
return None
|
||||
|
||||
offset = 1
|
||||
returned_path = decrypted[offset : offset + path_byte_len]
|
||||
offset += path_byte_len
|
||||
extra_type = decrypted[offset] & 0x0F
|
||||
offset += 1
|
||||
extra = decrypted[offset:]
|
||||
|
||||
return DecryptedPathPayload(
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
returned_path=returned_path,
|
||||
returned_path_len=returned_path_len,
|
||||
returned_path_hash_mode=hash_size - 1,
|
||||
extra_type=extra_type,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def try_decrypt_path(
|
||||
raw_packet: bytes,
|
||||
our_private_key: bytes,
|
||||
their_public_key: bytes,
|
||||
our_public_key: bytes,
|
||||
) -> DecryptedPathPayload | None:
|
||||
"""Try to decrypt a raw packet as a PATH packet."""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
|
||||
return None
|
||||
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = packet_info.payload[0]
|
||||
src_hash = packet_info.payload[1]
|
||||
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
|
||||
return None
|
||||
|
||||
try:
|
||||
shared_secret = derive_shared_secret(our_private_key, their_public_key)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
return decrypt_path_payload(packet_info.payload, shared_secret)
|
||||
|
||||
@@ -14,11 +14,11 @@ from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.dm_ingest import (
|
||||
ingest_fallback_direct_message,
|
||||
resolve_fallback_direct_message_context,
|
||||
)
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -197,11 +197,12 @@ async def on_path_update(event: "Event") -> None:
|
||||
)
|
||||
normalized_path_hash_mode = None
|
||||
|
||||
await ContactRepository.update_path(
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
str(path),
|
||||
normalized_path_len,
|
||||
normalized_path_hash_mode,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
|
||||
|
||||
@@ -268,19 +269,10 @@ async def on_ack(event: "Event") -> None:
|
||||
return
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is not None:
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
||||
# The frontend's mergePendingAck handles the missing field correctly,
|
||||
# preserving any previously known paths.
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info("ACK received for code %s", ack_code)
|
||||
else:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
|
||||
@@ -346,6 +346,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 44)
|
||||
applied += 1
|
||||
|
||||
# Migration 45: Replace legacy contact route columns with direct-route columns
|
||||
if version < 45:
|
||||
logger.info("Applying migration 45: rebuild contacts direct-route columns")
|
||||
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
|
||||
await set_version(conn, 45)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2635,3 +2642,134 @@ async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connectio
|
||||
WHERE type = 'PRIV' AND outgoing = 0"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Replace legacy contact route columns with canonical direct-route columns."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
target_columns = {
|
||||
"public_key",
|
||||
"name",
|
||||
"type",
|
||||
"flags",
|
||||
"direct_path",
|
||||
"direct_path_len",
|
||||
"direct_path_hash_mode",
|
||||
"direct_path_updated_at",
|
||||
"route_override_path",
|
||||
"route_override_len",
|
||||
"route_override_hash_mode",
|
||||
"last_advert",
|
||||
"lat",
|
||||
"lon",
|
||||
"last_seen",
|
||||
"on_radio",
|
||||
"last_contacted",
|
||||
"first_seen",
|
||||
"last_read_at",
|
||||
}
|
||||
if (
|
||||
target_columns.issubset(columns)
|
||||
and "last_path" not in columns
|
||||
and "out_path_hash_mode" not in columns
|
||||
):
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contacts_new (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
select_expr = {
|
||||
"public_key": "public_key",
|
||||
"name": "NULL",
|
||||
"type": "0",
|
||||
"flags": "0",
|
||||
"direct_path": "NULL",
|
||||
"direct_path_len": "NULL",
|
||||
"direct_path_hash_mode": "NULL",
|
||||
"direct_path_updated_at": "NULL",
|
||||
"route_override_path": "NULL",
|
||||
"route_override_len": "NULL",
|
||||
"route_override_hash_mode": "NULL",
|
||||
"last_advert": "NULL",
|
||||
"lat": "NULL",
|
||||
"lon": "NULL",
|
||||
"last_seen": "NULL",
|
||||
"on_radio": "0",
|
||||
"last_contacted": "NULL",
|
||||
"first_seen": "NULL",
|
||||
"last_read_at": "NULL",
|
||||
}
|
||||
for name in ("name", "type", "flags"):
|
||||
if name in columns:
|
||||
select_expr[name] = name
|
||||
|
||||
if "direct_path" in columns:
|
||||
select_expr["direct_path"] = "direct_path"
|
||||
|
||||
if "direct_path_len" in columns:
|
||||
select_expr["direct_path_len"] = "direct_path_len"
|
||||
|
||||
if "direct_path_hash_mode" in columns:
|
||||
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
|
||||
|
||||
for name in (
|
||||
"route_override_path",
|
||||
"route_override_len",
|
||||
"route_override_hash_mode",
|
||||
"last_advert",
|
||||
"lat",
|
||||
"lon",
|
||||
"last_seen",
|
||||
"on_radio",
|
||||
"last_contacted",
|
||||
"first_seen",
|
||||
"last_read_at",
|
||||
):
|
||||
if name in columns:
|
||||
select_expr[name] = name
|
||||
|
||||
ordered_columns = list(select_expr.keys())
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO contacts_new ({", ".join(ordered_columns)})
|
||||
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
|
||||
FROM contacts
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE contacts")
|
||||
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
|
||||
await conn.commit()
|
||||
|
||||
119
app/models.py
119
app/models.py
@@ -2,7 +2,17 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
|
||||
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
|
||||
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
|
||||
path_hash_mode: int = Field(
|
||||
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||
)
|
||||
|
||||
|
||||
class ContactUpsert(BaseModel):
|
||||
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
|
||||
name: str | None = None
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int | None = None
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int | None = None
|
||||
direct_path_hash_mode: int | None = None
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
flags=radio_data.get("flags", 0),
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
@@ -68,9 +79,10 @@ class Contact(BaseModel):
|
||||
name: str | None = None
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int = 0
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int = -1
|
||||
direct_path_hash_mode: int = -1
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
@@ -82,38 +94,99 @@ class Contact(BaseModel):
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
effective_route: ContactRoute | None = None
|
||||
effective_route_source: Literal["override", "direct", "flood"] = "flood"
|
||||
direct_route: ContactRoute | None = None
|
||||
route_override: ContactRoute | None = None
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
self.direct_path = direct_path or None
|
||||
self.direct_path_len = direct_path_len
|
||||
self.direct_path_hash_mode = direct_path_hash_mode
|
||||
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
self.route_override_path = route_override_path or None
|
||||
self.route_override_len = route_override_len
|
||||
self.route_override_hash_mode = route_override_hash_mode
|
||||
if (
|
||||
route_override_path is not None
|
||||
and route_override_len is not None
|
||||
and route_override_hash_mode is not None
|
||||
):
|
||||
self.route_override = ContactRoute(
|
||||
path=route_override_path,
|
||||
path_len=route_override_len,
|
||||
path_hash_mode=route_override_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.route_override = None
|
||||
|
||||
if direct_path_len >= 0:
|
||||
self.direct_route = ContactRoute(
|
||||
path=direct_path,
|
||||
path_len=direct_path_len,
|
||||
path_hash_mode=direct_path_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.direct_route = None
|
||||
|
||||
path, path_len, path_hash_mode = self.effective_route_tuple()
|
||||
if self.has_route_override():
|
||||
self.effective_route_source = "override"
|
||||
elif self.direct_route is not None:
|
||||
self.effective_route_source = "direct"
|
||||
else:
|
||||
self.effective_route_source = "flood"
|
||||
self.effective_route = ContactRoute(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
path_hash_mode=path_hash_mode,
|
||||
)
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route(self) -> tuple[str, int, int]:
|
||||
def effective_route_tuple(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
if self.direct_path_len >= 0:
|
||||
return normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
return "", -1, -1
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
than our database schema (name, direct_path, etc.).
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
"type": self.type,
|
||||
"flags": self.flags,
|
||||
"out_path": last_path,
|
||||
"out_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"out_path": effective_path,
|
||||
"out_path_len": effective_path_len,
|
||||
"out_path_hash_mode": effective_path_hash_mode,
|
||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
"Blank clears the override, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.decoder import (
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_decrypt_path,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
@@ -318,8 +320,7 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.ADVERT:
|
||||
# Process all advert arrivals (even payload-hash duplicates) so the
|
||||
# path-freshness logic in _process_advertisement can pick the shortest
|
||||
# path heard within the freshness window.
|
||||
# advert-history table retains recent path observations.
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
@@ -328,6 +329,9 @@ async def process_raw_packet(
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.PATH:
|
||||
await _process_path_packet(raw_bytes, ts, packet_info)
|
||||
|
||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
@@ -430,51 +434,20 @@ async def _process_advertisement(
|
||||
logger.debug("Failed to parse advertisement payload")
|
||||
return
|
||||
|
||||
# Extract path info from packet
|
||||
new_path_len = packet_info.path_length
|
||||
new_path_hex = packet_info.path.hex() if packet_info.path else ""
|
||||
|
||||
# Try to find existing contact
|
||||
existing = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
|
||||
# Determine which path to use: keep shorter path if heard recently (within 60s)
|
||||
# This handles advertisement echoes through different routes
|
||||
PATH_FRESHNESS_SECONDS = 60
|
||||
use_existing_path = False
|
||||
|
||||
if existing and existing.last_advert:
|
||||
path_age = timestamp - existing.last_advert
|
||||
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
|
||||
|
||||
# Keep existing path if it's fresh and shorter (or equal)
|
||||
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
|
||||
use_existing_path = True
|
||||
logger.debug(
|
||||
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
|
||||
advert.public_key[:12],
|
||||
existing_path_len,
|
||||
new_path_len,
|
||||
path_age,
|
||||
)
|
||||
|
||||
if use_existing_path:
|
||||
assert existing is not None # Guaranteed by the conditions that set use_existing_path
|
||||
path_len = existing.last_path_len if existing.last_path_len is not None else -1
|
||||
path_hex = existing.last_path or ""
|
||||
out_path_hash_mode = existing.out_path_hash_mode
|
||||
else:
|
||||
path_len = new_path_len
|
||||
path_hex = new_path_hex
|
||||
out_path_hash_mode = packet_info.path_hash_size - 1
|
||||
|
||||
logger.debug(
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
|
||||
advert.public_key[:12],
|
||||
advert.name,
|
||||
advert.device_role,
|
||||
advert.lat,
|
||||
advert.lon,
|
||||
path_len,
|
||||
new_path_len,
|
||||
)
|
||||
|
||||
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
|
||||
@@ -501,9 +474,6 @@ async def _process_advertisement(
|
||||
lon=advert.lon,
|
||||
last_advert=timestamp,
|
||||
last_seen=timestamp,
|
||||
last_path=path_hex,
|
||||
last_path_len=path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
@@ -667,3 +637,90 @@ async def _process_direct_message(
|
||||
# Couldn't decrypt with any known contact
|
||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||
return None
|
||||
|
||||
|
||||
async def _process_path_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
) -> None:
|
||||
"""Process a PATH packet and update the learned direct route."""
|
||||
if not has_private_key():
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
our_public_key = get_public_key()
|
||||
if private_key is None or our_public_key is None:
|
||||
return
|
||||
|
||||
if packet_info is None:
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
|
||||
return
|
||||
|
||||
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||
if dest_hash != our_first_byte:
|
||||
return
|
||||
|
||||
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
|
||||
if not candidate_contacts:
|
||||
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
|
||||
return
|
||||
|
||||
for contact in candidate_contacts:
|
||||
if len(contact.public_key) != 64:
|
||||
continue
|
||||
try:
|
||||
contact_public_key = bytes.fromhex(contact.public_key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_bytes,
|
||||
our_private_key=private_key,
|
||||
their_public_key=contact_public_key,
|
||||
our_public_key=our_public_key,
|
||||
)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
result.returned_path.hex(),
|
||||
result.returned_path_len,
|
||||
result.returned_path_hash_mode,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
|
||||
ack_code = result.extra[:4].hex()
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info(
|
||||
"Applied bundled PATH ACK for %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Buffered bundled PATH ACK %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
|
||||
logger.debug(
|
||||
"Observed bundled PATH RESPONSE from %s (%d bytes)",
|
||||
contact.public_key[:12],
|
||||
len(result.extra),
|
||||
)
|
||||
|
||||
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if refreshed_contact is not None:
|
||||
broadcast_event("contact", refreshed_contact.model_dump())
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
|
||||
)
|
||||
|
||||
@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
|
||||
def normalize_contact_route(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Normalize stored contact route fields.
|
||||
|
||||
Handles legacy/bad rows where the packed wire path byte was stored directly
|
||||
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
Returns `(path_hex, hop_count, hash_mode)`.
|
||||
"""
|
||||
normalized_path = path_hex or ""
|
||||
@@ -169,7 +169,7 @@ def normalize_contact_route(
|
||||
normalized_len = -1
|
||||
|
||||
try:
|
||||
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
|
||||
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_mode = None
|
||||
|
||||
@@ -207,7 +207,7 @@ def normalize_contact_route(
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
@@ -216,7 +216,7 @@ def normalize_route_override(
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
return {
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"direct_path": contact.direct_path,
|
||||
"direct_path_len": contact.direct_path_len,
|
||||
"direct_path_hash_mode": contact.direct_path_hash_mode,
|
||||
"direct_path_updated_at": contact.direct_path_updated_at,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
|
||||
@@ -36,11 +36,20 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
contact_row.last_path,
|
||||
contact_row.last_path_len,
|
||||
contact_row.out_path_hash_mode,
|
||||
)
|
||||
if (
|
||||
contact_row.direct_path is None
|
||||
and contact_row.direct_path_len is None
|
||||
and contact_row.direct_path_hash_mode is None
|
||||
):
|
||||
direct_path = None
|
||||
direct_path_len = None
|
||||
direct_path_hash_mode = None
|
||||
else:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
contact_row.direct_path,
|
||||
contact_row.direct_path_len,
|
||||
contact_row.direct_path_hash_mode,
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact_row.route_override_path,
|
||||
@@ -51,20 +60,25 @@ class ContactRepository:
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
out_path_hash_mode,
|
||||
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
|
||||
direct_path_hash_mode, direct_path_updated_at,
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
last_advert, lat, lon, last_seen,
|
||||
on_radio, last_contacted, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
flags = excluded.flags,
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
|
||||
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
|
||||
direct_path_hash_mode = COALESCE(
|
||||
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
|
||||
),
|
||||
direct_path_updated_at = COALESCE(
|
||||
excluded.direct_path_updated_at, contacts.direct_path_updated_at
|
||||
),
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
@@ -87,9 +101,10 @@ class ContactRepository:
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
direct_path,
|
||||
direct_path_len,
|
||||
direct_path_hash_mode,
|
||||
contact_row.direct_path_updated_at,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
@@ -107,12 +122,12 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
row["last_path"],
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
row["direct_path"] if "direct_path" in available_columns else None,
|
||||
row["direct_path_len"] if "direct_path_len" in available_columns else None,
|
||||
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
|
||||
)
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
@@ -136,9 +151,14 @@ class ContactRepository:
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
flags=row["flags"],
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
direct_path_updated_at=(
|
||||
row["direct_path_updated_at"]
|
||||
if "direct_path_updated_at" in available_columns
|
||||
else None
|
||||
),
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
@@ -286,26 +306,30 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(
|
||||
async def update_direct_path(
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
out_path_hash_mode: int | None = None,
|
||||
path_hash_mode: int | None = None,
|
||||
updated_at: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
ts = updated_at if updated_at is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
||||
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
|
||||
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
|
||||
direct_path_updated_at = ?,
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_path_len,
|
||||
normalized_hash_mode,
|
||||
int(time.time()),
|
||||
ts,
|
||||
ts,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
@@ -316,12 +340,12 @@ class ContactRepository:
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
|
||||
@@ -304,7 +304,6 @@ async def create_contact(
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
out_path_hash_mode=-1,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
@@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
return_len = int(payload.get("in_path_len") or 0)
|
||||
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
||||
|
||||
await ContactRepository.update_path(
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
forward_path,
|
||||
forward_len,
|
||||
@@ -524,9 +523,8 @@ async def set_contact_routing_override(
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood for %s",
|
||||
"Cleared routing override for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
|
||||
26
app/services/dm_ack_apply.py
Normal file
26
app/services/dm_ack_apply.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Shared direct-message ACK application logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
"""Apply a DM ACK code using the shared pending/buffered state machine.
|
||||
|
||||
Returns True when the ACK matched a pending message, False when it was buffered.
|
||||
"""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is None:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
return False
|
||||
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
|
||||
return True
|
||||
@@ -92,7 +92,6 @@ async def resolve_fallback_direct_message_context(
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
await contact_repository.upsert(placeholder_upsert)
|
||||
contact = await contact_repository.get_by_key(normalized_sender)
|
||||
|
||||
@@ -340,9 +340,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||
- Message stats: DM count, channel message count
|
||||
- Most active rooms (clickable → navigate to channel)
|
||||
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
|
||||
- Advert observation rate
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
- Recent advert paths (informational only; not part of DM route selection)
|
||||
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
getDirectContactRoute,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
@@ -134,11 +135,12 @@ export function ContactInfoPane({
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
||||
const directRoute = contact ? getDirectContactRoute(contact) : null;
|
||||
const pathHashModeLabel =
|
||||
effectiveRoute && effectiveRoute.pathLen >= 0
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
|
||||
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
||||
const isUnknownFullKeyResolvedContact =
|
||||
contact !== null &&
|
||||
@@ -330,7 +332,7 @@ export function ContactInfoPane({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
{hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
||||
)}
|
||||
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
|
||||
import {
|
||||
findContactsByPrefix,
|
||||
formatRouteLabel,
|
||||
getDirectContactRoute,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
|
||||
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
|
||||
|
||||
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
|
||||
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
|
||||
const hasForcedRoute = hasRoutingOverride(contact);
|
||||
const learnedRouteSummary = useMemo(() => {
|
||||
if (contact.last_path_len === -1) {
|
||||
if (!directRoute) {
|
||||
return 'Flood';
|
||||
}
|
||||
const hops = parsePathHops(contact.last_path, contact.last_path_len);
|
||||
const hops = parsePathHops(directRoute.path, directRoute.path_len);
|
||||
return hops.length > 0
|
||||
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(contact.last_path_len, true);
|
||||
}, [contact.last_path, contact.last_path_len]);
|
||||
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(directRoute.path_len, true);
|
||||
}, [directRoute]);
|
||||
const forcedRouteSummary = useMemo(() => {
|
||||
if (!hasForcedRoute) {
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Contact } from '../types';
|
||||
import {
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getDirectContactRoute,
|
||||
hasRoutingOverride,
|
||||
} from '../utils/pathUtils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
|
||||
}
|
||||
|
||||
function summarizeLearnedRoute(contact: Contact): string {
|
||||
return formatRouteLabel(contact.last_path_len, true);
|
||||
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
|
||||
}
|
||||
|
||||
function summarizeForcedRoute(contact: Contact): string | null {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { PathModal } from './PathModal';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -500,12 +501,13 @@ export function MessageList({
|
||||
parsedSender: string | null
|
||||
): SenderInfo => {
|
||||
if (msg.type === 'PRIV' && contact) {
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
return {
|
||||
name: contact.name || contact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: contact.public_key,
|
||||
lat: contact.lat,
|
||||
lon: contact.lon,
|
||||
pathHashMode: contact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'CHAN') {
|
||||
@@ -515,12 +517,13 @@ export function MessageList({
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (senderName ? getContactByName(senderName) : null);
|
||||
if (senderContact) {
|
||||
const directRoute = getDirectContactRoute(senderContact);
|
||||
return {
|
||||
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: senderContact.public_key,
|
||||
lat: senderContact.lat,
|
||||
lon: senderContact.lon,
|
||||
pathHashMode: senderContact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
if (senderName || msg.sender_key) {
|
||||
@@ -538,12 +541,13 @@ export function MessageList({
|
||||
if (parsedSender) {
|
||||
const senderContact = getContactByName(parsedSender);
|
||||
if (senderContact) {
|
||||
const directRoute = getDirectContactRoute(senderContact);
|
||||
return {
|
||||
name: parsedSender,
|
||||
publicKeyOrPrefix: senderContact.public_key,
|
||||
lat: senderContact.lat,
|
||||
lon: senderContact.lon,
|
||||
pathHashMode: senderContact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +293,8 @@ describe('App startup hash resolution', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -196,9 +196,9 @@ describe('ChatHeader key visibility', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AA',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -242,9 +242,9 @@ describe('ChatHeader key visibility', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AA',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'BBDD',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 0,
|
||||
|
||||
@@ -39,9 +39,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -104,7 +104,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
|
||||
it('shows hop width when contact has a stored path hash mode', async () => {
|
||||
const contact = createContact({ out_path_hash_mode: 1 });
|
||||
const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 });
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
@@ -117,7 +117,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
|
||||
it('does not show hop width for flood-routed contacts', async () => {
|
||||
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
|
||||
const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 });
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
@@ -131,8 +131,8 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
it('shows forced routing override and learned route separately', async () => {
|
||||
const contact = createContact({
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
|
||||
@@ -166,9 +166,9 @@ describe('ConversationPane', () => {
|
||||
name: 'Repeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -268,9 +268,9 @@ describe('ConversationPane', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -304,9 +304,9 @@ describe('ConversationPane', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -274,8 +274,8 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'TestNode',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -285,7 +285,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
|
||||
direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ describe('MapView', () => {
|
||||
name: 'Mystery Node',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -63,9 +63,9 @@ describe('MapView', () => {
|
||||
name: 'Almost Stale',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -23,9 +23,9 @@ const mockContact: Contact = {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -22,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'Test Contact',
|
||||
type: CONTACT_TYPE_REPEATER,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -33,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ describe('contact routing helpers', () => {
|
||||
it('prefers routing override over learned route', () => {
|
||||
const effective = getEffectiveContactRoute(
|
||||
createContact({
|
||||
last_path: 'AABB',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AABB',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
|
||||
@@ -82,9 +82,9 @@ const contacts: Contact[] = [
|
||||
name: 'TestRepeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -305,9 +305,9 @@ describe('RepeaterDashboard', () => {
|
||||
name: 'Neighbor',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -365,9 +365,9 @@ describe('RepeaterDashboard', () => {
|
||||
name: 'Neighbor',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -520,15 +520,15 @@ describe('RepeaterDashboard', () => {
|
||||
});
|
||||
|
||||
describe('path type display and reset', () => {
|
||||
it('shows flood when last_path_len is -1', () => {
|
||||
it('shows flood when direct_path_len is -1', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('flood')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows direct when last_path_len is 0', () => {
|
||||
it('shows direct when direct_path_len is 0', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
@@ -536,9 +536,9 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('direct')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows N hops when last_path_len > 0', () => {
|
||||
it('shows N hops when direct_path_len > 0', () => {
|
||||
const hoppedContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 3, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 3, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={hoppedContacts} />);
|
||||
@@ -548,7 +548,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('shows 1 hop (singular) for single hop', () => {
|
||||
const oneHopContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 1, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 1, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={oneHopContacts} />);
|
||||
@@ -558,7 +558,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('direct path is clickable, underlined, and marked as editable', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
@@ -573,7 +573,7 @@ describe('RepeaterDashboard', () => {
|
||||
const forcedContacts: Contact[] = [
|
||||
{
|
||||
...contacts[0],
|
||||
last_path_len: 1,
|
||||
direct_path_len: 1,
|
||||
last_seen: 1700000000,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
@@ -589,7 +589,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('clicking direct path opens modal and can force direct routing', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const { api } = await import('../api');
|
||||
@@ -613,7 +613,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('closing the routing override modal does not call the API', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const { api } = await import('../api');
|
||||
|
||||
@@ -231,9 +231,9 @@ describe('SearchView', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -27,9 +27,9 @@ function makeContact(
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -225,9 +225,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -242,9 +242,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -259,9 +259,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: null,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -42,9 +42,9 @@ function makeContact(suffix: string): Contact {
|
||||
name: `Contact-${suffix}`,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -200,9 +200,9 @@ describe('useConversationActions', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AABB',
|
||||
last_path_len: 2,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AABB',
|
||||
direct_path_len: 2,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -100,9 +100,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -142,9 +142,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -232,9 +232,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -44,9 +44,9 @@ function makeContact(pubkey: string): Contact {
|
||||
name: `Contact-${pubkey.slice(0, 6)}`,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -44,9 +44,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('useWebSocket dispatch', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -25,9 +25,9 @@ describe('wsEvents', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -50,9 +50,9 @@ describe('wsEvents', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -99,12 +99,17 @@ export interface Contact {
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
direct_path: string | null;
|
||||
direct_path_len: number;
|
||||
direct_path_hash_mode: number;
|
||||
direct_path_updated_at?: number | null;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
effective_route?: ContactRoute | null;
|
||||
effective_route_source?: 'override' | 'direct' | 'flood';
|
||||
direct_route?: ContactRoute | null;
|
||||
route_override?: ContactRoute | null;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
@@ -115,6 +120,12 @@ export interface Contact {
|
||||
first_seen: number | null;
|
||||
}
|
||||
|
||||
export interface ContactRoute {
|
||||
path: string;
|
||||
path_len: number;
|
||||
path_hash_mode: number;
|
||||
}
|
||||
|
||||
export interface ContactAdvertPath {
|
||||
path: string;
|
||||
path_len: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Contact, RadioConfig, MessagePath } from '../types';
|
||||
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const MAX_PATH_BYTES = 64;
|
||||
@@ -37,6 +37,7 @@ export interface EffectiveContactRoute {
|
||||
pathLen: number;
|
||||
pathHashMode: number;
|
||||
forced: boolean;
|
||||
source: 'override' | 'direct' | 'flood';
|
||||
}
|
||||
|
||||
function normalizePathHashMode(mode: number | null | undefined): number | null {
|
||||
@@ -114,29 +115,86 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
|
||||
}
|
||||
|
||||
export function hasRoutingOverride(contact: Contact): boolean {
|
||||
return contact.route_override_len !== null && contact.route_override_len !== undefined;
|
||||
return (
|
||||
(contact.route_override !== null && contact.route_override !== undefined) ||
|
||||
(contact.route_override_len !== null && contact.route_override_len !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDirectContactRoute(contact: Contact): ContactRoute | null {
|
||||
if (contact.direct_route) {
|
||||
return contact.direct_route;
|
||||
}
|
||||
|
||||
if (contact.direct_path_len < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: contact.direct_path ?? '',
|
||||
path_len: contact.direct_path_len,
|
||||
path_hash_mode:
|
||||
normalizePathHashMode(contact.direct_path_hash_mode) ??
|
||||
inferPathHashMode(contact.direct_path, contact.direct_path_len) ??
|
||||
0,
|
||||
};
|
||||
}
|
||||
|
||||
function getRouteOverride(contact: Contact): ContactRoute | null {
|
||||
if (contact.route_override) {
|
||||
return contact.route_override;
|
||||
}
|
||||
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathLen = contact.route_override_len ?? -1;
|
||||
let pathHashMode = normalizePathHashMode(contact.route_override_hash_mode);
|
||||
if (pathLen === -1) {
|
||||
pathHashMode = -1;
|
||||
} else if (pathHashMode == null) {
|
||||
pathHashMode = inferPathHashMode(contact.route_override_path, pathLen) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
path: contact.route_override_path ?? '',
|
||||
path_len: pathLen,
|
||||
path_hash_mode: pathHashMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
|
||||
const forced = hasRoutingOverride(contact);
|
||||
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
|
||||
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
|
||||
const route = contact.effective_route;
|
||||
if (route) {
|
||||
return {
|
||||
path: route.path || null,
|
||||
pathLen: route.path_len,
|
||||
pathHashMode: route.path_hash_mode,
|
||||
forced: contact.effective_route_source === 'override',
|
||||
source: contact.effective_route_source ?? 'flood',
|
||||
};
|
||||
}
|
||||
|
||||
let pathHashMode = forced
|
||||
? (contact.route_override_hash_mode ?? null)
|
||||
: (contact.out_path_hash_mode ?? null);
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
const overrideRoute = getRouteOverride(contact);
|
||||
const resolvedRoute = overrideRoute ?? directRoute;
|
||||
const source = overrideRoute ? 'override' : directRoute ? 'direct' : 'flood';
|
||||
const pathLen = resolvedRoute?.path_len ?? -1;
|
||||
let pathHashMode = resolvedRoute?.path_hash_mode ?? null;
|
||||
|
||||
if (pathLen === -1) {
|
||||
pathHashMode = -1;
|
||||
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
|
||||
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
|
||||
pathHashMode = inferPathHashMode(resolvedRoute?.path, pathLen) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
path: path || null,
|
||||
path: resolvedRoute?.path || null,
|
||||
pathLen,
|
||||
pathHashMode,
|
||||
forced,
|
||||
forced: source === 'override',
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,16 +209,17 @@ export function formatRouteLabel(pathLen: number, capitalize: boolean = false):
|
||||
}
|
||||
|
||||
export function formatRoutingOverrideInput(contact: Contact): string {
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
const routeOverride = getRouteOverride(contact);
|
||||
if (!routeOverride) {
|
||||
return '';
|
||||
}
|
||||
if (contact.route_override_len === -1) {
|
||||
if (routeOverride.path_len === -1) {
|
||||
return '-1';
|
||||
}
|
||||
if (contact.route_override_len === 0) {
|
||||
if (routeOverride.path_len === 0) {
|
||||
return '0';
|
||||
}
|
||||
return parsePathHops(contact.route_override_path, contact.route_override_len)
|
||||
return parsePathHops(routeOverride.path, routeOverride.path_len)
|
||||
.map((hop) => hop.toLowerCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface RadioConfig {
|
||||
cr: number;
|
||||
}
|
||||
|
||||
export type RadioAdvertMode = 'flood' | 'zero_hop';
|
||||
|
||||
export function getRadioConfig(): Promise<RadioConfig> {
|
||||
return fetchJson('/radio/config');
|
||||
}
|
||||
@@ -58,6 +60,13 @@ export function rebootRadio(): Promise<{ status: string; message: string }> {
|
||||
return fetchJson('/radio/reboot', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function sendAdvertisement(mode: RadioAdvertMode = 'flood'): Promise<{ status: string }> {
|
||||
return fetchJson('/radio/advertise', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Channels ---
|
||||
|
||||
export interface Channel {
|
||||
@@ -90,9 +99,9 @@ export interface Contact {
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
direct_path: string | null;
|
||||
direct_path_len: number;
|
||||
direct_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
@@ -128,6 +137,22 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> {
|
||||
return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getContactByKey(publicKey: string): Promise<Contact | undefined> {
|
||||
const normalized = publicKey.toLowerCase();
|
||||
const contacts = await getContacts(500, 0);
|
||||
return contacts.find((contact) => contact.public_key.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export function setContactRoutingOverride(
|
||||
publicKey: string,
|
||||
route: string
|
||||
): Promise<{ status: string; public_key: string }> {
|
||||
return fetchJson(`/contacts/${publicKey}/routing-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ route }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
export interface MessagePath {
|
||||
|
||||
135
tests/e2e/specs/dev-flightless-direct-route.spec.ts
Normal file
135
tests/e2e/specs/dev-flightless-direct-route.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createContact,
|
||||
deleteContact,
|
||||
getContactByKey,
|
||||
getMessages,
|
||||
setContactRoutingOverride,
|
||||
} from '../helpers/api';
|
||||
|
||||
const DEV_ONLY_ENV = 'MESHCORE_ENABLE_DEV_FLIGHTLESS_ROUTE_E2E';
|
||||
const FLIGHTLESS_NAME = 'FlightlessDt🥝';
|
||||
const FLIGHTLESS_PUBLIC_KEY =
|
||||
'ae92577bae6c269a1da3c87b5333e1bdb007e372b66e94204b9f92a6b52a62b1';
|
||||
const DEVELOPER_ONLY_NOTICE =
|
||||
`Developer-only hardware test. This scenario assumes ${FLIGHTLESS_NAME} ` +
|
||||
`(${FLIGHTLESS_PUBLIC_KEY.slice(0, 12)}...) is a nearby reachable node for the author's test radio. ` +
|
||||
`Set ${DEV_ONLY_ENV}=1 to run it intentionally.`;
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
test.describe('Developer-only direct-route learning for FlightlessDt🥝', () => {
|
||||
test('zero-hop adverts then DM ACK learns a direct route', { tag: '@developer-only' }, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({ type: 'notice', description: DEVELOPER_ONLY_NOTICE });
|
||||
if (process.env[DEV_ONLY_ENV] !== '1') {
|
||||
test.skip(true, DEVELOPER_ONLY_NOTICE);
|
||||
}
|
||||
|
||||
test.setTimeout(180_000);
|
||||
console.warn(`[developer-only e2e] ${DEVELOPER_ONLY_NOTICE}`);
|
||||
|
||||
try {
|
||||
await deleteContact(FLIGHTLESS_PUBLIC_KEY);
|
||||
} catch {
|
||||
// Best-effort reset; the contact may not exist yet in the temp E2E DB.
|
||||
}
|
||||
|
||||
await createContact(FLIGHTLESS_PUBLIC_KEY, FLIGHTLESS_NAME);
|
||||
await setContactRoutingOverride(FLIGHTLESS_PUBLIC_KEY, '');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
return contact?.direct_path_len ?? null;
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
message: 'Waiting for recreated FlightlessDt contact to start in flood mode',
|
||||
}
|
||||
)
|
||||
.toBe(-1);
|
||||
|
||||
await page.goto('/#settings/radio');
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
const zeroHopButton = page.getByRole('button', { name: 'Send Zero-Hop Advertisement' });
|
||||
await expect(zeroHopButton).toBeVisible();
|
||||
|
||||
await zeroHopButton.click();
|
||||
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.waitForTimeout(5_000);
|
||||
|
||||
await zeroHopButton.click();
|
||||
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Back to Chat/i })).toBeHidden({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const searchInput = page.getByLabel('Search conversations');
|
||||
await searchInput.fill(FLIGHTLESS_PUBLIC_KEY.slice(0, 12));
|
||||
await expect(page.getByText(FLIGHTLESS_NAME, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText(FLIGHTLESS_NAME, { exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 15_000,
|
||||
message: 'Waiting for FlightlessDt conversation route to load',
|
||||
})
|
||||
.toContain(`#contact/${encodeURIComponent(FLIGHTLESS_PUBLIC_KEY)}`);
|
||||
await expect(
|
||||
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(FLIGHTLESS_NAME)}`, 'i'))
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const text = `dev-flightless-direct-${Date.now()}`;
|
||||
const input = page.getByPlaceholder(/message/i);
|
||||
await input.fill(text);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(page.getByText(text)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await getMessages({
|
||||
type: 'PRIV',
|
||||
conversation_key: FLIGHTLESS_PUBLIC_KEY,
|
||||
limit: 25,
|
||||
});
|
||||
const match = messages.find((message) => message.outgoing && message.text === text);
|
||||
return match?.acked ?? 0;
|
||||
},
|
||||
{
|
||||
timeout: 90_000,
|
||||
message: 'Waiting for FlightlessDt DM ACK',
|
||||
}
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
return contact?.direct_path_len ?? null;
|
||||
},
|
||||
{
|
||||
timeout: 90_000,
|
||||
message: 'Waiting for FlightlessDt route to update from flood to direct',
|
||||
}
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const learnedContact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
expect(learnedContact?.direct_path ?? '').toBe('');
|
||||
|
||||
await page.locator('[title="View contact info"]').click();
|
||||
await expect(page.getByLabel('Contact Info')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,9 @@ async def _insert_contact(public_key, name="Alice"):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
|
||||
@@ -62,8 +62,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
|
||||
@@ -44,8 +44,9 @@ async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **over
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -307,9 +308,9 @@ class TestPathDiscovery:
|
||||
|
||||
updated = await ContactRepository.get_by_key(KEY_A)
|
||||
assert updated is not None
|
||||
assert updated.last_path == "11223344"
|
||||
assert updated.last_path_len == 2
|
||||
assert updated.out_path_hash_mode == 1
|
||||
assert updated.direct_path == "11223344"
|
||||
assert updated.direct_path_len == 2
|
||||
assert updated.direct_path_hash_mode == 1
|
||||
mc.commands.add_contact.assert_awaited()
|
||||
mock_broadcast.assert_called_once_with("contact", updated.model_dump())
|
||||
|
||||
@@ -527,7 +528,7 @@ class TestRoutingOverride:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_explicit_routing_override(self, test_db, client):
|
||||
await _insert_contact(KEY_A, last_path="11", last_path_len=1, out_path_hash_mode=0)
|
||||
await _insert_contact(KEY_A, direct_path="11", direct_path_len=1, direct_path_hash_mode=0)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
@@ -542,8 +543,8 @@ class TestRoutingOverride:
|
||||
assert response.status_code == 200
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
assert contact.direct_path == "11"
|
||||
assert contact.direct_path_len == 1
|
||||
assert contact.route_override_path == "ae92f13e"
|
||||
assert contact.route_override_len == 2
|
||||
assert contact.route_override_hash_mode == 1
|
||||
@@ -554,9 +555,9 @@ class TestRoutingOverride:
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
on_radio=True,
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="11",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
@@ -584,16 +585,17 @@ class TestRoutingOverride:
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len == -1
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
assert contact.direct_path == "11"
|
||||
assert contact.direct_path_len == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client):
|
||||
async def test_blank_route_clears_override_and_preserves_learned_path(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="11",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
direct_path_updated_at=1700000000,
|
||||
route_override_path="ae92f13e",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
@@ -613,9 +615,10 @@ class TestRoutingOverride:
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len is None
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
assert contact.direct_path == "11"
|
||||
assert contact.direct_path_len == 1
|
||||
assert contact.direct_path_hash_mode == 0
|
||||
assert contact.direct_path_updated_at == 1700000000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_explicit_route(self, test_db, client):
|
||||
|
||||
@@ -16,12 +16,14 @@ from app.decoder import (
|
||||
_clamp_scalar,
|
||||
decrypt_direct_message,
|
||||
decrypt_group_text,
|
||||
decrypt_path_payload,
|
||||
derive_public_key,
|
||||
derive_shared_secret,
|
||||
extract_payload,
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_decrypt_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -298,6 +300,181 @@ class TestGroupTextDecryption:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPathDecryption:
|
||||
"""Test PATH payload decryption against the firmware wire format."""
|
||||
|
||||
WORKED_PATH_PACKET = bytes.fromhex("22007EDE577469F4134F9B00EDD57EB4353A1B5999B7")
|
||||
WORKED_PATH_SENDER_PRIV = bytes.fromhex(
|
||||
"489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874B"
|
||||
"CC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308"
|
||||
)
|
||||
WORKED_PATH_DEST_PUB = bytes.fromhex(
|
||||
"7e23132922070404863fe855248ce414b64012c891342c1fc7ee5bd3d51ea405"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_encrypted_path_payload(
|
||||
*,
|
||||
shared_secret: bytes,
|
||||
dest_hash: int,
|
||||
src_hash: int,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
) -> bytes:
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
return bytes([dest_hash, src_hash]) + mac + ciphertext
|
||||
|
||||
def test_decrypt_path_payload_matches_firmware_layout(self):
|
||||
"""PATH packets are dest/src hashes plus MAC+ciphertext; decrypted data is path+extra."""
|
||||
shared_secret = bytes(range(32))
|
||||
payload = self._create_encrypted_path_payload(
|
||||
shared_secret=shared_secret,
|
||||
dest_hash=0xAE,
|
||||
src_hash=0x11,
|
||||
packed_path_len=0x42, # mode 1 (2-byte hops), 2 hops
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304"),
|
||||
)
|
||||
|
||||
result = decrypt_path_payload(payload, shared_secret)
|
||||
|
||||
assert result is not None
|
||||
assert result.dest_hash == "ae"
|
||||
assert result.src_hash == "11"
|
||||
assert result.returned_path == bytes.fromhex("aabbccdd")
|
||||
assert result.returned_path_len == 2
|
||||
assert result.returned_path_hash_mode == 1
|
||||
assert result.extra_type == PayloadType.ACK
|
||||
assert result.extra[:4] == bytes.fromhex("01020304")
|
||||
|
||||
def test_decrypt_path_payload_rejects_corrupted_mac(self):
|
||||
"""PATH payloads with a bad MAC must be rejected."""
|
||||
shared_secret = bytes(range(32))
|
||||
payload = self._create_encrypted_path_payload(
|
||||
shared_secret=shared_secret,
|
||||
dest_hash=0xAE,
|
||||
src_hash=0x11,
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x99\x88",
|
||||
)
|
||||
corrupted = payload[:2] + bytes([payload[2] ^ 0xFF, payload[3]]) + payload[4:]
|
||||
|
||||
result = decrypt_path_payload(corrupted, shared_secret)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decrypt_worked_path_packet_fixture(self):
|
||||
"""Worked PATH sample from the design doc decrypts as a direct route."""
|
||||
packet = parse_packet(self.WORKED_PATH_PACKET)
|
||||
assert packet is not None
|
||||
assert packet.payload_type == PayloadType.PATH
|
||||
|
||||
shared_secret = derive_shared_secret(
|
||||
self.WORKED_PATH_SENDER_PRIV, self.WORKED_PATH_DEST_PUB
|
||||
)
|
||||
result = decrypt_path_payload(packet.payload, shared_secret)
|
||||
|
||||
assert result is not None
|
||||
assert result.dest_hash == "7e"
|
||||
assert result.src_hash == "de"
|
||||
assert result.returned_path == b""
|
||||
assert result.returned_path_len == 0
|
||||
assert result.returned_path_hash_mode == 0
|
||||
assert result.extra_type == 0x0F
|
||||
|
||||
|
||||
class TestTryDecryptPath:
|
||||
"""Test the full PATH decryption wrapper."""
|
||||
|
||||
OUR_PRIV = bytes.fromhex(
|
||||
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
|
||||
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
|
||||
)
|
||||
THEIR_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
|
||||
|
||||
@classmethod
|
||||
def _make_path_packet(
|
||||
cls,
|
||||
*,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
) -> bytes:
|
||||
shared_secret = derive_shared_secret(cls.OUR_PRIV, cls.THEIR_PUB)
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
our_public = derive_public_key(cls.OUR_PRIV)
|
||||
return (
|
||||
bytes([(PayloadType.PATH << 2) | RouteType.DIRECT, 0x00])
|
||||
+ bytes([our_public[0], cls.THEIR_PUB[0]])
|
||||
+ mac
|
||||
+ ciphertext
|
||||
)
|
||||
|
||||
def test_try_decrypt_path_decrypts_full_packet(self):
|
||||
"""try_decrypt_path validates hashes, derives ECDH, and returns the route."""
|
||||
raw_packet = self._make_path_packet(
|
||||
packed_path_len=0x42,
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304"),
|
||||
)
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_packet,
|
||||
our_private_key=self.OUR_PRIV,
|
||||
their_public_key=self.THEIR_PUB,
|
||||
our_public_key=derive_public_key(self.OUR_PRIV),
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.returned_path == bytes.fromhex("aabbccdd")
|
||||
assert result.returned_path_len == 2
|
||||
assert result.returned_path_hash_mode == 1
|
||||
assert result.extra_type == PayloadType.ACK
|
||||
assert result.extra[:4] == bytes.fromhex("01020304")
|
||||
|
||||
def test_try_decrypt_path_rejects_hash_mismatch(self):
|
||||
"""Packets addressed to another destination are rejected before decryption."""
|
||||
raw_packet = self._make_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\xaa",
|
||||
)
|
||||
wrong_our_public = bytes.fromhex("ff") + derive_public_key(self.OUR_PRIV)[1:]
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_packet,
|
||||
our_private_key=self.OUR_PRIV,
|
||||
their_public_key=self.THEIR_PUB,
|
||||
our_public_key=wrong_our_public,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestTryDecryptPacket:
|
||||
"""Test the full packet decryption pipeline."""
|
||||
|
||||
|
||||
@@ -559,7 +559,6 @@ class TestDualPathDedup:
|
||||
"last_contacted": SENDER_TIMESTAMP,
|
||||
"first_seen": SENDER_TIMESTAMP,
|
||||
"on_radio": False,
|
||||
"out_path_hash_mode": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -631,7 +630,6 @@ class TestDualPathDedup:
|
||||
"last_contacted": SENDER_TIMESTAMP,
|
||||
"first_seen": SENDER_TIMESTAMP,
|
||||
"on_radio": False,
|
||||
"out_path_hash_mode": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -706,7 +704,6 @@ class TestDualPathDedup:
|
||||
"last_contacted": SENDER_TIMESTAMP,
|
||||
"first_seen": SENDER_TIMESTAMP,
|
||||
"on_radio": False,
|
||||
"out_path_hash_mode": 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -850,9 +850,9 @@ class TestOnPathUpdate:
|
||||
# Verify path was updated in DB
|
||||
contact = await ContactRepository.get_by_key("aa" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "0102"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.out_path_hash_mode == 0
|
||||
assert contact.direct_path == "0102"
|
||||
assert contact.direct_path_len == 2
|
||||
assert contact.direct_path_hash_mode == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_path_hash_mode_when_present(self, test_db):
|
||||
@@ -880,9 +880,9 @@ class TestOnPathUpdate:
|
||||
|
||||
contact = await ContactRepository.get_by_key("ab" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "aa00bb00"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.out_path_hash_mode == 1
|
||||
assert contact.direct_path == "aa00bb00"
|
||||
assert contact.direct_path_len == 2
|
||||
assert contact.direct_path_hash_mode == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_nothing_when_contact_not_found(self, test_db):
|
||||
@@ -924,8 +924,8 @@ class TestOnPathUpdate:
|
||||
|
||||
contact = await ContactRepository.get_by_key("bb" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "0a0b"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.direct_path == "0a0b"
|
||||
assert contact.direct_path_len == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_path_fields_does_not_modify_contact(self, test_db):
|
||||
@@ -940,7 +940,7 @@ class TestOnPathUpdate:
|
||||
"flags": 0,
|
||||
}
|
||||
)
|
||||
await ContactRepository.update_path("dd" * 32, "beef", 2)
|
||||
await ContactRepository.update_direct_path("dd" * 32, "beef", 2)
|
||||
|
||||
class MockEvent:
|
||||
payload = {"public_key": "dd" * 32}
|
||||
@@ -949,8 +949,8 @@ class TestOnPathUpdate:
|
||||
|
||||
contact = await ContactRepository.get_by_key("dd" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "beef"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.direct_path == "beef"
|
||||
assert contact.direct_path_len == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_identity_fields_noop(self, test_db):
|
||||
@@ -965,7 +965,7 @@ class TestOnPathUpdate:
|
||||
"flags": 0,
|
||||
}
|
||||
)
|
||||
await ContactRepository.update_path("ee" * 32, "abcd", 2)
|
||||
await ContactRepository.update_direct_path("ee" * 32, "abcd", 2)
|
||||
|
||||
class MockEvent:
|
||||
payload = {}
|
||||
@@ -974,8 +974,8 @@ class TestOnPathUpdate:
|
||||
|
||||
contact = await ContactRepository.get_by_key("ee" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "abcd"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.direct_path == "abcd"
|
||||
assert contact.direct_path_len == 2
|
||||
|
||||
|
||||
class TestOnNewContact:
|
||||
|
||||
@@ -1201,8 +1201,8 @@ class TestMigration039:
|
||||
"""Test migration 039: persist contacts.out_path_hash_mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_column_and_backfills_legacy_rows(self):
|
||||
"""Pre-039 contacts get flood=-1 and legacy routed paths=0."""
|
||||
async def test_legacy_advert_paths_do_not_become_direct_routes_after_upgrade(self):
|
||||
"""Pre-045 advert-derived last_path data is dropped from active direct-route columns."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
@@ -1247,29 +1247,31 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_path_len, out_path_hash_mode
|
||||
SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode
|
||||
FROM contacts
|
||||
ORDER BY public_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "aa" * 32
|
||||
assert rows[0]["last_path_len"] == -1
|
||||
assert rows[0]["out_path_hash_mode"] == -1
|
||||
assert rows[0]["direct_path"] is None
|
||||
assert rows[0]["direct_path_len"] is None
|
||||
assert rows[0]["direct_path_hash_mode"] is None
|
||||
assert rows[1]["public_key"] == "bb" * 32
|
||||
assert rows[1]["last_path_len"] == 1
|
||||
assert rows[1]["out_path_hash_mode"] == 0
|
||||
assert rows[1]["direct_path"] is None
|
||||
assert rows[1]["direct_path_len"] is None
|
||||
assert rows[1]["direct_path_hash_mode"] is None
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_valid_modes_are_preserved_when_column_already_exists(self):
|
||||
"""Migration does not clobber post-upgrade multibyte rows."""
|
||||
async def test_legacy_out_path_hash_mode_is_not_promoted_into_direct_routes(self):
|
||||
"""Pre-045 out_path_hash_mode does not make advert paths become active direct routes."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
@@ -1317,12 +1319,12 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, out_path_hash_mode
|
||||
SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode
|
||||
FROM contacts
|
||||
WHERE public_key IN (?, ?)
|
||||
ORDER BY public_key
|
||||
@@ -1331,9 +1333,75 @@ class TestMigration039:
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "cc" * 32
|
||||
assert rows[0]["out_path_hash_mode"] == 1
|
||||
assert rows[0]["direct_path"] is None
|
||||
assert rows[0]["direct_path_len"] is None
|
||||
assert rows[0]["direct_path_hash_mode"] is None
|
||||
assert rows[1]["public_key"] == "dd" * 32
|
||||
assert rows[1]["out_path_hash_mode"] == -1
|
||||
assert rows[1]["direct_path"] is None
|
||||
assert rows[1]["direct_path_len"] is None
|
||||
assert rows[1]["direct_path_hash_mode"] is None
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_direct_route_columns_are_preserved(self):
|
||||
"""Already-migrated databases keep canonical direct-route data intact."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await set_version(conn, 44)
|
||||
await conn.execute("""
|
||||
CREATE TABLE contacts (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (
|
||||
public_key, name, direct_path, direct_path_len, direct_path_hash_mode,
|
||||
direct_path_updated_at, last_seen
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("ee" * 32, "Direct", "aa00bb00", 2, 1, 123456, 123457),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT direct_path, direct_path_len, direct_path_hash_mode, direct_path_updated_at
|
||||
FROM contacts
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
("ee" * 32,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row["direct_path"] == "aa00bb00"
|
||||
assert row["direct_path_len"] == 2
|
||||
assert row["direct_path_hash_mode"] == 1
|
||||
assert row["direct_path_updated_at"] == 123456
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -1371,8 +1439,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1433,8 +1501,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1486,8 +1554,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
|
||||
@@ -12,10 +12,20 @@ from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.decoder import DecryptedDirectMessage, PacketInfo, ParsedAdvertisement, PayloadType
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
PacketInfo,
|
||||
ParsedAdvertisement,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
derive_public_key,
|
||||
derive_shared_secret,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
@@ -27,6 +37,43 @@ with open(FIXTURES_PATH) as f:
|
||||
FIXTURES = json.load(f)
|
||||
|
||||
|
||||
PATH_TEST_OUR_PRIV = bytes.fromhex(
|
||||
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
|
||||
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
|
||||
)
|
||||
PATH_TEST_CONTACT_PUB = bytes.fromhex(
|
||||
"a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
|
||||
)
|
||||
PATH_TEST_OUR_PUB = derive_public_key(PATH_TEST_OUR_PRIV)
|
||||
|
||||
|
||||
def _build_path_packet(
|
||||
*,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
route_type: RouteType = RouteType.DIRECT,
|
||||
) -> bytes:
|
||||
shared_secret = derive_shared_secret(PATH_TEST_OUR_PRIV, PATH_TEST_CONTACT_PUB)
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
mac = hmac.new(shared_secret, ciphertext, sha256).digest()[:2]
|
||||
header = bytes([(PayloadType.PATH << 2) | route_type, 0x00])
|
||||
payload = bytes([PATH_TEST_OUR_PUB[0], PATH_TEST_CONTACT_PUB[0]]) + mac + ciphertext
|
||||
return header + payload
|
||||
|
||||
|
||||
class TestChannelMessagePipeline:
|
||||
"""Test channel message flow: packet → decrypt → store → broadcast."""
|
||||
|
||||
@@ -169,10 +216,13 @@ class TestAdvertisementPipeline:
|
||||
assert contact.lon is not None
|
||||
assert abs(contact.lat - expected["lat"]) < 0.001
|
||||
assert abs(contact.lon - expected["lon"]) < 0.001
|
||||
# This advertisement has path_len=6 (6 hops through repeaters)
|
||||
assert contact.last_path_len == 6
|
||||
assert contact.last_path is not None
|
||||
assert len(contact.last_path) == 12 # 6 bytes = 12 hex chars
|
||||
assert contact.direct_path_len == -1
|
||||
assert contact.direct_path in (None, "")
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path_len == 6
|
||||
assert len(advert_paths[0].path) == 12 # 6 bytes = 12 hex chars
|
||||
|
||||
# Verify WebSocket broadcast
|
||||
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
|
||||
@@ -182,7 +232,8 @@ class TestAdvertisementPipeline:
|
||||
assert broadcast["data"]["public_key"] == expected["public_key"]
|
||||
assert broadcast["data"]["name"] == expected["name"]
|
||||
assert broadcast["data"]["type"] == expected["type"]
|
||||
assert broadcast["data"]["last_path_len"] == 6
|
||||
assert broadcast["data"]["direct_path_len"] == -1
|
||||
assert "last_path_len" not in broadcast["data"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_updates_existing_contact(self, test_db, captured_broadcasts):
|
||||
@@ -216,10 +267,11 @@ class TestAdvertisementPipeline:
|
||||
assert contact.type == expected["type"] # Type updated
|
||||
assert contact.lat is not None # GPS added
|
||||
assert contact.lon is not None
|
||||
# This advertisement has path_len=0 (direct neighbor)
|
||||
assert contact.last_path_len == 0
|
||||
# Empty path stored as None or ""
|
||||
assert contact.last_path in (None, "")
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path_len == 0
|
||||
assert advert_paths[0].path == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_triggers_historical_decrypt_for_new_contact(
|
||||
@@ -278,42 +330,38 @@ class TestAdvertisementPipeline:
|
||||
assert mock_start.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_keeps_shorter_path_within_window(
|
||||
async def test_advertisement_records_recent_unique_paths_without_changing_direct_route(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""When receiving echoed advertisements, keep the shortest path within 60s window."""
|
||||
"""Advertisement paths are informational and do not replace the stored direct route."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
# Create a contact with a longer path (path_len=3)
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": test_pubkey,
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"direct_path": "aabbcc",
|
||||
"direct_path_len": 3,
|
||||
"direct_path_hash_mode": 0,
|
||||
"last_advert": 1000,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": 3,
|
||||
"last_path": "aabbcc", # 3 bytes = 3 hops
|
||||
}
|
||||
)
|
||||
|
||||
# Simulate receiving a shorter path (path_len=1) within 60s
|
||||
# We'll call _process_advertisement directly with mock packet_info
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.decoder import ParsedAdvertisement
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
# Mock packet_info with shorter path
|
||||
short_packet_info = MagicMock()
|
||||
short_packet_info.path_length = 1
|
||||
short_packet_info.path = bytes.fromhex("aa")
|
||||
short_packet_info.path_hash_size = 1
|
||||
short_packet_info.payload = b"" # Will be parsed by parse_advertisement
|
||||
short_packet_info.payload = b""
|
||||
|
||||
# Mock parse_advertisement to return our test contact
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
with patch("app.packet_processor.parse_advertisement") as mock_parse:
|
||||
mock_parse.return_value = ParsedAdvertisement(
|
||||
@@ -324,18 +372,13 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process at timestamp 1050 (within 60s of last_seen=1000)
|
||||
await _process_advertisement(b"", timestamp=1050, packet_info=short_packet_info)
|
||||
|
||||
# Verify the shorter path was stored
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 1 # Updated to shorter path
|
||||
|
||||
# Now simulate receiving a longer path (path_len=5) - should keep the shorter one
|
||||
long_packet_info = MagicMock()
|
||||
long_packet_info.path_length = 5
|
||||
long_packet_info.path = bytes.fromhex("aabbccddee")
|
||||
long_packet_info.path_hash_size = 1
|
||||
long_packet_info.payload = b""
|
||||
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
with patch("app.packet_processor.parse_advertisement") as mock_parse:
|
||||
@@ -347,35 +390,33 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process at timestamp 1055 (within 60s of last update)
|
||||
await _process_advertisement(b"", timestamp=1055, packet_info=long_packet_info)
|
||||
|
||||
# Verify the shorter path was kept
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 1 # Still the shorter path
|
||||
assert contact is not None
|
||||
assert contact.direct_path == "aabbcc"
|
||||
assert contact.direct_path_len == 3
|
||||
assert contact.direct_path_hash_mode == 0
|
||||
assert contact.direct_path_updated_at is None
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("aabbccddee", 5),
|
||||
("aa", 1),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_path_freshness_uses_receive_time_not_sender_clock(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""Sender clock skew should not keep an old advert path artificially fresh."""
|
||||
"""Advert history timestamps use receive time instead of sender clock."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.decoder import ParsedAdvertisement
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": test_pubkey,
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_advert": 1000,
|
||||
"last_seen": 1055, # Simulates later non-advert activity
|
||||
"last_path_len": 1,
|
||||
"last_path": "aa",
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert({"public_key": test_pubkey, "name": "TestNode", "type": 1})
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
@@ -419,19 +460,19 @@ class TestAdvertisementPipeline:
|
||||
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact is not None
|
||||
assert contact.last_path_len == 3
|
||||
assert contact.last_path == "aabbcc"
|
||||
assert contact.last_advert == 1200
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.last_seen) for path in advert_paths] == [
|
||||
("aabbcc", 1200),
|
||||
("aa", 1070),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_default_path_len_treated_as_infinity(
|
||||
async def test_advertisement_records_path_history_when_no_direct_route_exists(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""Contact with last_path_len=-1 (unset) is treated as infinite length.
|
||||
|
||||
Any new advertisement should replace the default -1 path since
|
||||
the code converts -1 to float('inf') for comparison.
|
||||
"""
|
||||
"""Advertisement path history is still recorded when no direct route exists."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
@@ -441,8 +482,6 @@ class TestAdvertisementPipeline:
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": -1, # Default unset value
|
||||
"last_path": None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -465,23 +504,21 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process within 60s window (last_seen=1000, now=1050)
|
||||
await _process_advertisement(b"", timestamp=1050, packet_info=packet_info)
|
||||
|
||||
# Since -1 is treated as infinity, the new path (len=3) should replace it
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 3
|
||||
assert contact.last_path == "aabbcc"
|
||||
assert contact is not None
|
||||
assert contact.direct_path_len == -1
|
||||
assert contact.direct_path in (None, "")
|
||||
assert contact.direct_path_updated_at is None
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path == "aabbcc"
|
||||
assert advert_paths[0].path_len == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_replaces_stale_path_outside_window(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""When existing path is stale (>60s), a new longer path should replace it.
|
||||
|
||||
In a mesh network, a stale short path may no longer be valid (node moved, repeater
|
||||
went offline). Accepting the new longer path ensures we have a working route.
|
||||
"""
|
||||
async def test_advertisement_adds_new_unique_history_path(self, test_db, captured_broadcasts):
|
||||
"""A new advertisement path is added to history even when an older path already exists."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
@@ -491,10 +528,9 @@ class TestAdvertisementPipeline:
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": 1, # Short path
|
||||
"last_path": "aa",
|
||||
}
|
||||
)
|
||||
await ContactAdvertPathRepository.record_observation(test_pubkey, "aa", 1000, hop_count=1)
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -521,10 +557,158 @@ class TestAdvertisementPipeline:
|
||||
)
|
||||
await _process_advertisement(b"", timestamp=1061, packet_info=long_packet_info)
|
||||
|
||||
# Verify the longer path replaced the stale shorter one
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 4
|
||||
assert contact.last_path == "aabbccdd"
|
||||
assert contact is not None
|
||||
assert contact.direct_path_len == -1
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("aabbccdd", 4),
|
||||
("aa", 1),
|
||||
]
|
||||
|
||||
|
||||
class TestPathPacketPipeline:
|
||||
"""Test PATH packet learning and bundled ACK handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_raw_path_packet_updates_direct_route(self, test_db, captured_broadcasts):
|
||||
"""A decryptable PATH packet updates the contact's learned direct route."""
|
||||
from app.packet_processor import process_raw_packet
|
||||
|
||||
timestamp = 1700000200
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x42,
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x11\x22",
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex(),
|
||||
"name": "PathPeer",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
):
|
||||
result = await process_raw_packet(raw_packet, timestamp=timestamp)
|
||||
|
||||
assert result["payload_type"] == "PATH"
|
||||
contact = await ContactRepository.get_by_key(PATH_TEST_CONTACT_PUB.hex())
|
||||
assert contact is not None
|
||||
assert contact.direct_path == "aabbccdd"
|
||||
assert contact.direct_path_len == 2
|
||||
assert contact.direct_path_hash_mode == 1
|
||||
assert contact.direct_path_updated_at == timestamp
|
||||
|
||||
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
|
||||
assert len(contact_broadcasts) == 1
|
||||
assert contact_broadcasts[0]["data"]["effective_route_source"] == "direct"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bundled_path_ack_marks_message_acked(self, test_db, captured_broadcasts):
|
||||
"""Bundled ACKs inside PATH packets satisfy the pending DM ACK tracker."""
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.services import dm_ack_tracker
|
||||
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304feedface"),
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex(),
|
||||
"name": "AckPeer",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="waiting for ack",
|
||||
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
|
||||
sender_timestamp=1700000000,
|
||||
received_at=1700000000,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
prev_pending = dm_ack_tracker._pending_acks.copy()
|
||||
prev_buffered = dm_ack_tracker._buffered_acks.copy()
|
||||
dm_ack_tracker._pending_acks.clear()
|
||||
dm_ack_tracker._buffered_acks.clear()
|
||||
dm_ack_tracker.track_pending_ack("01020304", message_id, 30000)
|
||||
dm_ack_tracker.track_pending_ack("05060708", message_id, 30000)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
try:
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
):
|
||||
await process_raw_packet(raw_packet, timestamp=1700000300)
|
||||
finally:
|
||||
pending_after = dm_ack_tracker._pending_acks.copy()
|
||||
dm_ack_tracker._pending_acks.clear()
|
||||
dm_ack_tracker._pending_acks.update(prev_pending)
|
||||
dm_ack_tracker._buffered_acks.clear()
|
||||
dm_ack_tracker._buffered_acks.update(prev_buffered)
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
msg_type="PRIV",
|
||||
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
|
||||
limit=10,
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].acked == 1
|
||||
assert "01020304" not in pending_after
|
||||
assert "05060708" not in pending_after
|
||||
|
||||
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
|
||||
assert len(ack_broadcasts) == 1
|
||||
assert ack_broadcasts[0]["data"] == {"message_id": message_id, "ack_count": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_only_contacts_are_skipped_for_path_decryption(self, test_db):
|
||||
"""Prefix-only contacts are not treated as valid ECDH peers for PATH packets."""
|
||||
from app.packet_processor import _process_path_packet
|
||||
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x01",
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex()[:12],
|
||||
"name": "PrefixOnly",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
patch(
|
||||
"app.packet_processor.try_decrypt_path",
|
||||
side_effect=AssertionError("prefix-only contacts should be skipped"),
|
||||
),
|
||||
):
|
||||
await _process_path_packet(raw_packet, 1700000400, None)
|
||||
|
||||
|
||||
class TestAckPipeline:
|
||||
@@ -1694,8 +1878,12 @@ class TestProcessRawPacketIntegration:
|
||||
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact is not None
|
||||
assert contact.last_path_len == 1 # Shorter path won
|
||||
assert contact.last_path == "dd"
|
||||
assert contact.direct_path_len == -1
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("dd", 1),
|
||||
("aabbcc", 3),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatches_text_message(self, test_db, captured_broadcasts):
|
||||
|
||||
@@ -200,16 +200,16 @@ class TestParseExplicitHopRoute:
|
||||
|
||||
|
||||
class TestContactToRadioDictHashMode:
|
||||
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
|
||||
"""Test that Contact.to_radio_dict() preserves the stored direct-route hash mode."""
|
||||
|
||||
def test_preserves_1byte_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="aa" * 32,
|
||||
last_path="1a2b3c",
|
||||
last_path_len=3,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="1a2b3c",
|
||||
direct_path_len=3,
|
||||
direct_path_hash_mode=0,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 0
|
||||
@@ -219,9 +219,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="bb" * 32,
|
||||
last_path="1a2b3c4d",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="1a2b3c4d",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
@@ -231,9 +231,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="cc" * 32,
|
||||
last_path="1a2b3c4d5e6f",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="1a2b3c4d5e6f",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 2
|
||||
@@ -243,9 +243,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="dd" * 32,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=-1,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
@@ -255,9 +255,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="ee" * 32,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
@@ -267,9 +267,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="ff" * 32,
|
||||
last_path="3f3f69de1c7b7e7662",
|
||||
last_path_len=-125,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="3f3f69de1c7b7e7662",
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path"] == "3f3f69de1c7b7e7662"
|
||||
@@ -281,9 +281,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="11" * 32,
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
@@ -309,7 +309,9 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_hash_mode": 1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
assert d["direct_path"] == "aa00bb00"
|
||||
assert d["direct_path_len"] == 2
|
||||
assert d["direct_path_hash_mode"] == 1
|
||||
|
||||
def test_flood_falls_back_to_minus_one(self):
|
||||
from app.models import Contact
|
||||
@@ -322,4 +324,6 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_len": -1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
assert d["direct_path"] == ""
|
||||
assert d["direct_path_len"] == -1
|
||||
assert d["direct_path_hash_mode"] == -1
|
||||
|
||||
@@ -356,9 +356,9 @@ class TestDiscoverMesh:
|
||||
name=None,
|
||||
type=2,
|
||||
flags=0,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
last_advert=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
@@ -418,9 +418,9 @@ class TestDiscoverMesh:
|
||||
name="Known",
|
||||
type=4,
|
||||
flags=0,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
last_advert=None,
|
||||
lat=None,
|
||||
lon=None,
|
||||
|
||||
@@ -71,9 +71,9 @@ async def _insert_contact(
|
||||
contact_type=0,
|
||||
last_contacted=None,
|
||||
last_advert=None,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
):
|
||||
"""Insert a contact into the test database."""
|
||||
await ContactRepository.upsert(
|
||||
@@ -82,9 +82,9 @@ async def _insert_contact(
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"last_path": last_path,
|
||||
"last_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"direct_path": direct_path,
|
||||
"direct_path_len": direct_path_len,
|
||||
"direct_path_hash_mode": direct_path_hash_mode,
|
||||
"last_advert": last_advert,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -597,9 +597,9 @@ class TestSyncAndOffloadAll:
|
||||
KEY_A,
|
||||
"Alice",
|
||||
last_contacted=2000,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
@@ -626,9 +626,9 @@ class TestSyncAndOffloadAll:
|
||||
KEY_A,
|
||||
"Alice",
|
||||
last_contacted=2000,
|
||||
last_path="3f3f69de1c7b7e7662",
|
||||
last_path_len=-125,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="3f3f69de1c7b7e7662",
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
|
||||
@@ -58,8 +58,9 @@ async def _insert_contact(public_key: str, name: str = "Node", contact_type: int
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
|
||||
@@ -750,7 +750,7 @@ class TestContactRepositoryUpsertContracts:
|
||||
name="Bob",
|
||||
type=2,
|
||||
on_radio=True,
|
||||
out_path_hash_mode=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -83,8 +83,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -152,16 +153,16 @@ class TestOutgoingDMBroadcast:
|
||||
assert "ambiguous" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_preserves_stored_out_path_hash_mode(self, test_db):
|
||||
async def test_send_dm_preserves_stored_direct_path_hash_mode(self, test_db):
|
||||
"""Direct-message send pushes the persisted path hash mode back to the radio."""
|
||||
mc = _make_mc()
|
||||
pub_key = "cd" * 32
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -185,9 +186,9 @@ class TestOutgoingDMBroadcast:
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
|
||||
Reference in New Issue
Block a user