mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
18 Commits
3.5.0
...
bot-safety
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b73bef30b | ||
|
|
4b583fe337 | ||
|
|
e6e7267eb1 | ||
|
|
36eeeae64d | ||
|
|
7c988ae3d0 | ||
|
|
1a0c4833d5 | ||
|
|
84c500d018 | ||
|
|
1960a16fb0 | ||
|
|
3580aeda5a | ||
|
|
bb97b983bb | ||
|
|
da31b67d54 | ||
|
|
d840159f9c | ||
|
|
9de4158a6c | ||
|
|
1e21644d74 | ||
|
|
df0ed8452b | ||
|
|
d4a5f0f728 | ||
|
|
3e2c48457d | ||
|
|
d4f518df0c |
@@ -1,3 +1,11 @@
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
Feature: BYOPacket for analysis
|
||||
Feature: Add room activity to stats view
|
||||
Bugfix: Handle Heltec v3 serial noise
|
||||
Misc: Swap repeaters and room servers for better ordering
|
||||
|
||||
## [3.5.0] - 2026-03-19
|
||||
|
||||
Feature: Add room server alpha support
|
||||
|
||||
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.3.1) — MIT
|
||||
### meshcore (2.3.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
@@ -192,7 +192,7 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
packet_id: NotRequired[int | None]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
|
||||
@@ -82,6 +82,21 @@ class FanoutManager:
|
||||
def __init__(self) -> None:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
self._bots_disabled_until_restart = False
|
||||
|
||||
def get_bots_disabled_source(self) -> str | None:
|
||||
"""Return why bot modules are unavailable, if at all."""
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
return "env"
|
||||
if self._bots_disabled_until_restart:
|
||||
return "until_restart"
|
||||
return None
|
||||
|
||||
def bots_disabled_effective(self) -> bool:
|
||||
"""Return True when bot modules should be treated as unavailable."""
|
||||
return self.get_bots_disabled_source() is not None
|
||||
|
||||
async def load_from_db(self) -> None:
|
||||
"""Read enabled fanout_configs and instantiate modules."""
|
||||
@@ -99,13 +114,14 @@ class FanoutManager:
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
return
|
||||
# Skip bot modules when bots are disabled server-wide or until restart.
|
||||
if config_type == "bot" and self.bots_disabled_effective():
|
||||
logger.info(
|
||||
"Skipping bot module %s (bots disabled: %s)",
|
||||
config_id,
|
||||
self.get_bots_disabled_source(),
|
||||
)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
@@ -240,6 +256,26 @@ class FanoutManager:
|
||||
}
|
||||
return result
|
||||
|
||||
async def disable_bots_until_restart(self) -> str:
|
||||
"""Stop active bot modules and prevent them from starting again until restart."""
|
||||
source = self.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return source
|
||||
|
||||
self._bots_disabled_until_restart = True
|
||||
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
bot_ids = [
|
||||
config_id
|
||||
for config_id in list(self._modules)
|
||||
if _configs_cache.get(config_id, {}).get("type") == "bot"
|
||||
]
|
||||
for config_id in bot_ids:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
return "until_restart"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
|
||||
@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
if path == "api" or path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a "
|
||||
"frontend request, you may be running a newer frontend with an older "
|
||||
"backend or vice versa. A full update is suggested."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
file_path = (frontend_dir / path).resolve()
|
||||
try:
|
||||
file_path.relative_to(frontend_dir)
|
||||
|
||||
@@ -413,6 +413,10 @@ class Message(BaseModel):
|
||||
acked: int = 0
|
||||
sender_name: str | None = None
|
||||
channel_name: str | None = None
|
||||
packet_id: int | None = Field(
|
||||
default=None,
|
||||
description="Representative raw packet row ID when archival raw bytes exist",
|
||||
)
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
@@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class RawPacketDetail(BaseModel):
|
||||
"""Stored raw-packet detail returned by the packet API."""
|
||||
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
|
||||
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
|
||||
rssi: int | None = Field(
|
||||
default=None, description="Received signal strength in dBm if available"
|
||||
)
|
||||
decrypted: bool = False
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
@@ -814,4 +833,5 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
@@ -331,6 +331,12 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
packet_id = None
|
||||
if hasattr(row, "keys"):
|
||||
row_keys = row.keys()
|
||||
if "packet_id" in row_keys:
|
||||
packet_id = row["packet_id"]
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
@@ -345,6 +351,14 @@ class MessageRepository:
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
sender_name=row["sender_name"],
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _message_select(message_alias: str = "messages") -> str:
|
||||
return (
|
||||
f"{message_alias}.*, "
|
||||
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -363,7 +377,7 @@ class MessageRepository:
|
||||
) -> list[Message]:
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
@@ -470,7 +484,8 @@ class MessageRepository:
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
target_cursor = await db.conn.execute(
|
||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
||||
f"SELECT {MessageRepository._message_select('messages')} "
|
||||
f"FROM messages WHERE id = ? AND {where_sql}",
|
||||
(message_id, *base_params),
|
||||
)
|
||||
target_row = await target_cursor.fetchone()
|
||||
@@ -481,7 +496,7 @@ class MessageRepository:
|
||||
|
||||
# 2. Get context_size+1 messages before target (DESC)
|
||||
before_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||
"""
|
||||
@@ -500,7 +515,7 @@ class MessageRepository:
|
||||
|
||||
# 3. Get context_size+1 messages after target (ASC)
|
||||
after_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||
"""
|
||||
@@ -545,7 +560,7 @@ class MessageRepository:
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM messages WHERE id = ?",
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -570,7 +585,9 @@ class MessageRepository:
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
query = """
|
||||
SELECT * FROM messages
|
||||
SELECT messages.*,
|
||||
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
"""
|
||||
|
||||
@@ -121,6 +121,18 @@ class RawPacketRepository:
|
||||
return None
|
||||
return row["message_id"]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
|
||||
"""Return a raw packet row as (id, data, timestamp, message_id)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
|
||||
(packet_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
|
||||
@@ -270,6 +270,30 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
||||
FROM messages m
|
||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
||||
WHERE m.type = 'CHAN'
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
@@ -396,6 +420,7 @@ class StatisticsRepository:
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
return {
|
||||
@@ -411,5 +436,6 @@ class StatisticsRepository:
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import string
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
return {"messages": messages, "raw_packets": raw_packets}
|
||||
|
||||
|
||||
def _bot_system_disabled_detail() -> str | None:
|
||||
source = fanout_manager.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
|
||||
if source == "until_restart":
|
||||
return "Bot system disabled until the server restarts"
|
||||
return None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_fanout_configs() -> list[dict]:
|
||||
"""List all fanout configs."""
|
||||
@@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if body.type == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(cfg["id"])
|
||||
|
||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||
@@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
if existing["type"] == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if existing["type"] == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
@@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Reload the module to pick up changes
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(config_id)
|
||||
|
||||
logger.info("Updated fanout config %s", config_id)
|
||||
@@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Stop the module first
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/bots/disable-until-restart")
|
||||
async def disable_bots_until_restart() -> dict:
|
||||
"""Stop active bot modules and prevent them from running again until restart."""
|
||||
source = await fanout_manager.disable_bots_until_restart()
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": source,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
bots_disabled: bool = False
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
def _clean_optional_str(value: object) -> str | None:
|
||||
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _read_optional_bool_setting(name: str) -> bool:
|
||||
value = getattr(settings, name, False)
|
||||
return value if isinstance(value, bool) else False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
||||
app_build_info = get_app_build_info()
|
||||
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
|
||||
try:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
|
||||
if manager_bots_disabled_source is not None:
|
||||
bots_disabled_source = manager_bots_disabled_source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": settings.disable_bots,
|
||||
"bots_disabled": bots_disabled_source is not None,
|
||||
"bots_disabled_source": bots_disabled_source,
|
||||
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import RawPacketDecryptedInfo, RawPacketDetail
|
||||
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
||||
from app.repository import ChannelRepository, RawPacketRepository
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/{packet_id}", response_model=RawPacketDetail)
|
||||
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||
"""Fetch one stored raw packet by row ID for on-demand inspection."""
|
||||
packet_row = await RawPacketRepository.get_by_id(packet_id)
|
||||
if packet_row is None:
|
||||
raise HTTPException(status_code=404, detail="Raw packet not found")
|
||||
|
||||
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
|
||||
packet_info = parse_packet(packet_data)
|
||||
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
|
||||
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
if message_id is not None:
|
||||
message = await MessageRepository.get_by_id(message_id)
|
||||
if message is not None:
|
||||
if message.type == "CHAN":
|
||||
channel = await ChannelRepository.get_by_key(message.conversation_key)
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
channel_name=channel.name if channel else None,
|
||||
sender=message.sender_name,
|
||||
channel_key=message.conversation_key,
|
||||
contact_key=message.sender_key,
|
||||
)
|
||||
else:
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
sender=message.sender_name,
|
||||
contact_key=message.conversation_key,
|
||||
)
|
||||
|
||||
return RawPacketDetail(
|
||||
id=stored_packet_id,
|
||||
timestamp=packet_timestamp,
|
||||
data=packet_data.hex(),
|
||||
payload_type=payload_type_name,
|
||||
decrypted=message_id is not None,
|
||||
decrypted_info=decrypted_info,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||
async def decrypt_historical_packets(
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
||||
|
||||
@@ -238,6 +238,7 @@ async def _store_direct_message(
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def build_message_model(
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
@@ -79,6 +80,7 @@ def build_message_model(
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -131,6 +133,7 @@ def broadcast_message_acked(
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
packet_id: int | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
@@ -140,6 +143,7 @@ def broadcast_message_acked(
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
"packet_id": packet_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -182,11 +186,16 @@ async def reconcile_duplicate_message(
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
representative_packet_id = (
|
||||
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
|
||||
)
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
packet_id=representative_packet_id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -307,6 +316,7 @@ async def create_message_from_decrypted(
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
|
||||
@@ -18,15 +18,18 @@ services:
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
||||
|
||||
# Serial (USB)
|
||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# Security
|
||||
# MESHCORE_DISABLE_BOTS: "true"
|
||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
@@ -247,6 +248,7 @@ export const api = {
|
||||
),
|
||||
|
||||
// Packets
|
||||
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
decryptHistoricalPackets: (params: {
|
||||
key_type: 'channel' | 'contact';
|
||||
@@ -341,6 +343,14 @@ export const api = {
|
||||
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
disableBotsUntilRestart: () =>
|
||||
fetchJson<{
|
||||
status: string;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source: 'env' | 'until_restart';
|
||||
}>('/fanout/bots/disable-until-restart', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import {
|
||||
@@ -289,6 +290,7 @@ export function AppShell({
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
|
||||
@@ -261,6 +261,7 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
|
||||
@@ -611,7 +611,8 @@ export function CrackerPanel({
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time.
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
false-positives.
|
||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
||||
if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
|
||||
@@ -8,19 +8,23 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
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 { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
contacts: Contact[];
|
||||
channels?: Channel[];
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||
const ANALYZE_PACKET_NOTICE =
|
||||
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
|
||||
|
||||
function hasUnexpectedControlChars(text: string): boolean {
|
||||
for (const char of text) {
|
||||
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
channels = [],
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
@@ -199,10 +206,18 @@ export function MessageList({
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
| { kind: 'unavailable'; message: string }
|
||||
| null
|
||||
>(null);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||
@@ -221,6 +236,43 @@ export function MessageList({
|
||||
// Track conversation key to detect when entire message set changes
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message:
|
||||
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = packetCacheRef.current.get(message.packet_id);
|
||||
if (cached) {
|
||||
setPacketInspectorSource({ kind: 'packet', packet: cached });
|
||||
return;
|
||||
}
|
||||
|
||||
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
|
||||
|
||||
try {
|
||||
const packet = await api.getPacket(message.packet_id);
|
||||
packetCacheRef.current.set(message.packet_id, packet);
|
||||
setPacketInspectorSource({ kind: 'packet', packet });
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isMissing = error instanceof Error && /not found/i.test(error.message);
|
||||
if (!isMissing) {
|
||||
toast.error('Failed to load raw packet', { description });
|
||||
}
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message: isMissing
|
||||
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
|
||||
: `Could not load the archival raw packet for this message: ${description}`,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -833,6 +885,8 @@ export function MessageList({
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -859,6 +913,8 @@ export function MessageList({
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -879,6 +935,7 @@ export function MessageList({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
@@ -900,6 +957,7 @@ export function MessageList({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
@@ -997,9 +1055,31 @@ export function MessageList({
|
||||
contacts={contacts}
|
||||
config={config ?? null}
|
||||
messageId={selectedPath.messageId}
|
||||
packetId={selectedPath.packetId}
|
||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||
isResendable={isSelectedMessageResendable}
|
||||
onResend={onResendChannelMessage}
|
||||
onAnalyzePacket={
|
||||
selectedPath.packetId != null
|
||||
? () => {
|
||||
const message = messages.find((entry) => entry.id === selectedPath.messageId);
|
||||
if (message) {
|
||||
void handleAnalyzePacket(message);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,11 @@ interface PathModalProps {
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
isResendable?: boolean;
|
||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onAnalyzePacket?: () => void;
|
||||
}
|
||||
|
||||
export function PathModal({
|
||||
@@ -42,14 +44,17 @@ export function PathModal({
|
||||
contacts,
|
||||
config,
|
||||
messageId,
|
||||
packetId,
|
||||
isOutgoingChan,
|
||||
isResendable,
|
||||
onResend,
|
||||
onAnalyzePacket,
|
||||
}: PathModalProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
|
||||
|
||||
// Resolve all paths
|
||||
const resolvedPaths = hasPaths
|
||||
@@ -90,6 +95,12 @@ export function PathModal({
|
||||
|
||||
{hasPaths && (
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{showAnalyzePacket ? (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
inspectRawPacketWithOptions,
|
||||
type PacketByteField,
|
||||
} from '../utils/rawPacketInspector';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface RawPacketDetailModalProps {
|
||||
@@ -16,6 +18,38 @@ interface RawPacketDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RawPacketInspectorDialogSource =
|
||||
| {
|
||||
kind: 'packet';
|
||||
packet: RawPacket;
|
||||
}
|
||||
| {
|
||||
kind: 'paste';
|
||||
}
|
||||
| {
|
||||
kind: 'loading';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'unavailable';
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channels: Channel[];
|
||||
source: RawPacketInspectorDialogSource;
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
interface FieldPaletteEntry {
|
||||
box: string;
|
||||
boxActive: string;
|
||||
@@ -358,6 +392,36 @@ function renderFieldValue(field: PacketByteField) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePacketHex(input: string): string {
|
||||
return input.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function validatePacketHex(input: string): string | null {
|
||||
if (!input) {
|
||||
return 'Paste a packet hex string to analyze.';
|
||||
}
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return 'Packet hex may only contain 0-9 and A-F characters.';
|
||||
}
|
||||
if (input.length % 2 !== 0) {
|
||||
return 'Packet hex must contain an even number of characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPastedRawPacket(packetHex: string): RawPacket {
|
||||
return {
|
||||
id: -1,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
data: packetHex,
|
||||
payload_type: 'Unknown',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
palette,
|
||||
@@ -500,145 +564,256 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
[channels]
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
|
||||
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
||||
[decoderOptions, packet]
|
||||
);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
|
||||
const packetDisplayFields = useMemo(
|
||||
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(
|
||||
() => (inspection ? buildDisplayFields(inspection) : []),
|
||||
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
||||
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||
const packetContext = useMemo(
|
||||
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
|
||||
() => getPacketContext(packet, inspection, groupTextCandidates),
|
||||
[groupTextCandidates, inspection, packet]
|
||||
);
|
||||
const packetIsDecrypted = useMemo(
|
||||
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
|
||||
() => packetShowsDecryptedState(packet, inspection),
|
||||
[inspection, packet]
|
||||
);
|
||||
|
||||
if (!packet || !inspection) {
|
||||
return null;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(packet.data);
|
||||
toast.success('Packet hex copied!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channels,
|
||||
source,
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || source.kind !== 'paste') {
|
||||
setPacketInput('');
|
||||
}
|
||||
}, [open, source.kind]);
|
||||
|
||||
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
||||
const packetInputError = useMemo(
|
||||
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
||||
[normalizedPacketInput]
|
||||
);
|
||||
const analyzedPacket = useMemo(
|
||||
() =>
|
||||
normalizedPacketInput.length > 0 && packetInputError === null
|
||||
? buildPastedRawPacket(normalizedPacketInput)
|
||||
: null,
|
||||
[normalizedPacketInput, packetInputError]
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
||||
Packet Hex
|
||||
</label>
|
||||
<textarea
|
||||
id="raw-packet-input"
|
||||
value={packetInput}
|
||||
onChange={(event) => setPacketInput(event.target.value)}
|
||||
placeholder="Paste raw packet hex here..."
|
||||
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{packetInputError ? (
|
||||
<div className="text-sm text-destructive">{packetInputError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{analyzedPacket ? (
|
||||
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Paste a packet above to inspect it.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (source.kind === 'loading') {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>Packet Details</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Detailed byte and field breakdown for the selected raw packet.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
{notice ? (
|
||||
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{notice}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
if (!packet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RawPacketInspectorDialog
|
||||
open={packet !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && onClose()}
|
||||
channels={channels}
|
||||
source={{ kind: 'packet', packet }}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketDetailModal } from './RawPacketDetailModal';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
@@ -385,6 +386,7 @@ export function RawPacketFeedView({
|
||||
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
@@ -418,7 +420,6 @@ export function RawPacketFeedView({
|
||||
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.newestNeighbors]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
@@ -428,15 +429,26 @@ export function RawPacketFeedView({
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
@@ -599,10 +611,26 @@ export function RawPacketFeedView({
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RawPacketDetailModal
|
||||
packet={selectedPacket}
|
||||
<RawPacketInspectorDialog
|
||||
open={selectedPacket !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
|
||||
channels={channels}
|
||||
onClose={() => setSelectedPacket(null)}
|
||||
source={
|
||||
selectedPacket
|
||||
? { kind: 'packet', packet: selectedPacket }
|
||||
: { kind: 'loading', message: 'Loading packet...' }
|
||||
}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
|
||||
<RawPacketInspectorDialog
|
||||
open={analyzeModalOpen}
|
||||
onOpenChange={setAnalyzeModalOpen}
|
||||
channels={channels}
|
||||
source={{ kind: 'paste' }}
|
||||
title="Analyze Packet"
|
||||
description="Paste and inspect a raw packet hex string."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import type {
|
||||
Contact,
|
||||
PaneState,
|
||||
@@ -59,7 +60,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
useRememberedServerPassword('room', contact.public_key);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginMessage, setLoginMessage] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
@@ -74,7 +74,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
useEffect(() => {
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
setAuthenticated(false);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
@@ -135,20 +134,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
setAuthenticated(true);
|
||||
setLoginMessage(
|
||||
result.message ??
|
||||
(result.authenticated
|
||||
? 'Login confirmed. You can now send room messages and open admin tools.'
|
||||
: 'Login request sent, but authentication was not confirmed.')
|
||||
);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
} else {
|
||||
toast(result.message ?? 'Room login was not confirmed');
|
||||
toast.warning('Room login not confirmed', {
|
||||
description: result.message ?? 'Room login was not confirmed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -251,62 +245,69 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Room Server Controls</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Room access is active. Use the chat history and message box below to participate, and
|
||||
open admin tools when needed.
|
||||
</p>
|
||||
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Room Server Tools</SheetTitle>
|
||||
<SheetDescription>
|
||||
Room server telemetry, ACL tools, sensor data, and CLI console
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { HealthStatus } from '../types';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
|
||||
|
||||
function readAcknowledgedState(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAcknowledgedState(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, 'true');
|
||||
} catch {
|
||||
// Best effort only; the warning will continue to show if localStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
interface SecurityWarningModalProps {
|
||||
health: HealthStatus | null;
|
||||
}
|
||||
|
||||
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
|
||||
const [confirmedRisk, setConfirmedRisk] = useState(false);
|
||||
const [disablingBots, setDisablingBots] = useState(false);
|
||||
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
|
||||
|
||||
const shouldWarn =
|
||||
health !== null &&
|
||||
health.bots_disabled !== true &&
|
||||
health.basic_auth_enabled !== true &&
|
||||
!botsDisabledLocally &&
|
||||
!acknowledged;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldWarn) {
|
||||
setConfirmedRisk(false);
|
||||
}
|
||||
}, [shouldWarn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (health?.bots_disabled !== true) {
|
||||
setBotsDisabledLocally(false);
|
||||
}
|
||||
}, [health?.bots_disabled, health?.bots_disabled_source]);
|
||||
|
||||
if (!shouldWarn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="space-y-0 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="leading-tight">
|
||||
Unprotected bot execution is enabled
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||
<DialogDescription>
|
||||
Bots are enabled, and app-wide Basic Auth is not configured.
|
||||
</DialogDescription>
|
||||
<p>
|
||||
Without one of those protections, or another access-control layer in front of
|
||||
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||
the computer hosting this instance.
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
This is only safe on protected or isolated networks with appropriate access control. If
|
||||
your network is untrusted or later compromised, this setup may expose the host system to
|
||||
arbitrary code execution.
|
||||
</p>
|
||||
<p>
|
||||
To reduce that risk, run the server with environment variables to either disable bots
|
||||
with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_DISABLE_BOTS=true
|
||||
</code>{' '}
|
||||
or enable the built-in login with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_USERNAME
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_PASSWORD
|
||||
</code>
|
||||
. Another external auth or access-control system is also acceptable.
|
||||
</p>
|
||||
<p>
|
||||
If you just want a temporary safety measure while you learn the system, you can use the
|
||||
button below to disable bots until the server restarts. That is only a temporary guard;
|
||||
permanent protection through Basic Auth or env-based bot disablement is still
|
||||
encouraged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
disabled={disablingBots}
|
||||
onClick={async () => {
|
||||
setDisablingBots(true);
|
||||
try {
|
||||
await api.disableBotsUntilRestart();
|
||||
setBotsDisabledLocally(true);
|
||||
toast.success('Bots disabled until restart');
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable bots', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDisablingBots(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={confirmedRisk}
|
||||
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
|
||||
aria-label="Acknowledge bot security risk"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm leading-6 text-foreground">
|
||||
I understand that continuing with my existing security setup may put me at risk on
|
||||
untrusted networks or if my home network is compromised.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
variant="outline"
|
||||
disabled={!confirmedRisk || disablingBots}
|
||||
onClick={() => {
|
||||
writeAcknowledgedState();
|
||||
setAcknowledged(true);
|
||||
}}
|
||||
>
|
||||
Do Not Warn Me On This Device Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -945,21 +945,6 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Repeaters */}
|
||||
{nonFavoriteRepeaters.length > 0 && (
|
||||
<>
|
||||
@@ -975,6 +960,21 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteRooms.length === 0 &&
|
||||
|
||||
@@ -173,8 +173,8 @@ export function SettingsDatabaseSection({
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<em className="text-muted-foreground/80">
|
||||
This will not affect any displayed messages or app functionality, nor impact your
|
||||
ability to do historical decryption.
|
||||
This will not affect any displayed messages or your ability to do historical decryption,
|
||||
but it will remove packet-analysis availability for those historical messages.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
|
||||
@@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({
|
||||
|
||||
{health?.bots_disabled && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
|
||||
cannot be created or modified.
|
||||
{health.bots_disabled_source === 'until_restart'
|
||||
? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
|
||||
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">Known-channels active</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_week}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -110,6 +110,8 @@ export function useBrowserNotifications() {
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const blockedDescription =
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
@@ -131,7 +133,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
description: blockedDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -159,9 +161,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
|
||||
@@ -86,7 +86,12 @@ export class ConversationMessageCache {
|
||||
return true;
|
||||
}
|
||||
|
||||
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
|
||||
updateAck(
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
const index = entry.messages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) continue;
|
||||
@@ -96,6 +101,7 @@ export class ConversationMessageCache {
|
||||
...current,
|
||||
acked: Math.max(current.acked, ackCount),
|
||||
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
entry.messages = updated;
|
||||
return;
|
||||
@@ -146,12 +152,16 @@ export function reconcileConversationMessages(
|
||||
current: Message[],
|
||||
fetched: Message[]
|
||||
): Message[] | null {
|
||||
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
|
||||
const currentById = new Map<
|
||||
number,
|
||||
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
|
||||
>();
|
||||
for (const message of current) {
|
||||
currentById.set(message.id, {
|
||||
acked: message.acked,
|
||||
pathsLen: message.paths?.length ?? 0,
|
||||
text: message.text,
|
||||
packetId: message.packet_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +172,8 @@ export function reconcileConversationMessages(
|
||||
!currentMessage ||
|
||||
currentMessage.acked !== message.acked ||
|
||||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
|
||||
currentMessage.text !== message.text
|
||||
currentMessage.text !== message.text ||
|
||||
currentMessage.packetId !== message.packet_id
|
||||
) {
|
||||
needsUpdate = true;
|
||||
break;
|
||||
@@ -180,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
|
||||
interface PendingAckUpdate {
|
||||
ackCount: number;
|
||||
paths?: MessagePath[];
|
||||
packetId?: number | null;
|
||||
}
|
||||
|
||||
export function mergePendingAck(
|
||||
existing: PendingAckUpdate | undefined,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[]
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): PendingAckUpdate {
|
||||
if (!existing) {
|
||||
return {
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +213,9 @@ export function mergePendingAck(
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
...(packetId === undefined &&
|
||||
existing.packetId !== undefined && { packetId: existing.packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,16 +223,31 @@ export function mergePendingAck(
|
||||
return existing;
|
||||
}
|
||||
|
||||
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
|
||||
|
||||
if (paths === undefined) {
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingPathCount = existing.paths?.length ?? -1;
|
||||
if (paths.length >= existingPathCount) {
|
||||
return { ackCount, paths };
|
||||
return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
|
||||
}
|
||||
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseConversationMessagesResult {
|
||||
@@ -230,7 +262,12 @@ interface UseConversationMessagesResult {
|
||||
jumpToBottom: () => void;
|
||||
reloadCurrentConversation: () => void;
|
||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
reconcileOnReconnect: () => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
@@ -291,9 +328,9 @@ export function useConversationMessages(
|
||||
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
||||
|
||||
const setPendingAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const existing = pendingAcksRef.current.get(messageId);
|
||||
const merged = mergePendingAck(existing, ackCount, paths);
|
||||
const merged = mergePendingAck(existing, ackCount, paths, packetId);
|
||||
|
||||
// Update insertion order so most recent updates remain in the buffer longest.
|
||||
pendingAcksRef.current.delete(messageId);
|
||||
@@ -319,6 +356,7 @@ export function useConversationMessages(
|
||||
...msg,
|
||||
acked: Math.max(msg.acked, pending.ackCount),
|
||||
...(pending.paths !== undefined && { paths: pending.paths }),
|
||||
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
|
||||
};
|
||||
}, []);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -782,10 +820,10 @@ export function useConversationMessages(
|
||||
|
||||
// Update a message's ack count and paths
|
||||
const updateMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
|
||||
if (!hasMessageLoaded) {
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -807,10 +845,11 @@ export function useConversationMessages(
|
||||
...current,
|
||||
acked: nextAck,
|
||||
...(paths !== undefined && { paths: nextPaths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
@@ -818,9 +857,9 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const receiveMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths);
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
updateMessageAck(messageId, ackCount, paths, packetId);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
[updateMessageAck]
|
||||
);
|
||||
|
||||
@@ -48,7 +48,12 @@ interface UseRealtimeAppStateArgs {
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
maxRawPackets?: number;
|
||||
@@ -246,8 +251,13 @@ export function useRealtimeAppState({
|
||||
recordRawPacketObservation?.(packet);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
receiveMessageAck(messageId, ackCount, paths);
|
||||
onMessageAcked: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => {
|
||||
receiveMessageAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -114,6 +114,15 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
|
||||
renderSection({
|
||||
health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const BOT_CHANNEL: Channel = {
|
||||
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
|
||||
name: '#bot',
|
||||
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
|
||||
};
|
||||
|
||||
describe('RawPacketDetailModal', () => {
|
||||
it('copies the full packet hex to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
|
||||
});
|
||||
|
||||
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('analyzes a pasted raw packet without adding it to the live feed', () => {
|
||||
renderView({ channels: [TEST_CHANNEL] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Hex'), {
|
||||
target: { value: GROUP_TEXT_PACKET_HEX },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stats by default on desktop', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
|
||||
const { api: _rawApi } = await import('../api');
|
||||
const mockApi = _rawApi as unknown as Record<string, Mock>;
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as Record<string, Mock>;
|
||||
|
||||
const roomContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -63,9 +65,13 @@ describe('RoomServerPanel', () => {
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Room Server Controls')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
||||
description:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
});
|
||||
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableBotsUntilRestart: vi.fn(),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
disableBotsUntilRestart: mocks.disableBotsUntilRestart,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
import { SecurityWarningModal } from '../components/SecurityWarningModal';
|
||||
import type { HealthStatus } from '../types';
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'degraded',
|
||||
radio_connected: false,
|
||||
radio_initializing: false,
|
||||
connection_info: null,
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
basic_auth_enabled: false,
|
||||
};
|
||||
|
||||
describe('SecurityWarningModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mocks.disableBotsUntilRestart.mockResolvedValue({
|
||||
status: 'ok',
|
||||
bots_disabled: true,
|
||||
bots_disabled_source: 'until_restart',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the warning when bots are enabled and basic auth is off', () => {
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show when bots are disabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show when basic auth is enabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dismissal only after the checkbox is acknowledged', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', {
|
||||
name: 'Do Not Warn Me On This Device Again',
|
||||
});
|
||||
await user.click(screen.getByLabelText('Acknowledge bot security risk'));
|
||||
expect(dismissButton).toBeEnabled();
|
||||
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables bots until restart from the warning modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
|
||||
expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SecurityWarningModal
|
||||
health={{ ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -557,6 +557,10 @@ describe('SettingsModal', () => {
|
||||
renderModal();
|
||||
openDatabaseSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -580,6 +584,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
@@ -626,6 +631,7 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
|
||||
// Busiest channels
|
||||
expect(screen.getByText('general')).toBeInTheDocument();
|
||||
@@ -646,6 +652,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
|
||||
@@ -148,4 +148,25 @@ describe('useBrowserNotifications', () => {
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the browser guidance toast when notifications are blocked', async () => {
|
||||
Object.assign(window.Notification, {
|
||||
permission: 'denied',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
|
||||
description:
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('useWebSocket dispatch', () => {
|
||||
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths, packetId)', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('useWebSocket dispatch', () => {
|
||||
fireMessage({ type, data });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledOnce();
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined, undefined);
|
||||
});
|
||||
|
||||
it('routes message_acked with paths', () => {
|
||||
@@ -154,7 +154,16 @@ describe('useWebSocket dispatch', () => {
|
||||
const paths = [{ path: 'aabb', received_at: 1700000000 }];
|
||||
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths, undefined);
|
||||
});
|
||||
|
||||
it('routes message_acked with packet_id', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, packet_id: 99 } });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, undefined, 99);
|
||||
});
|
||||
|
||||
it('routes error event to onError', () => {
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface HealthStatus {
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
fanout_statuses: Record<string, FanoutStatusEntry>;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source?: 'env' | 'until_restart' | null;
|
||||
basic_auth_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FanoutConfig {
|
||||
@@ -267,6 +269,7 @@ export interface Message {
|
||||
acked: number;
|
||||
sender_name: string | null;
|
||||
channel_name?: string | null;
|
||||
packet_id?: number | null;
|
||||
}
|
||||
|
||||
export interface MessagesAroundResponse {
|
||||
@@ -513,6 +516,7 @@ export interface StatisticsResponse {
|
||||
total_outgoing: number;
|
||||
contacts_heard: ContactActivityCounts;
|
||||
repeaters_heard: ContactActivityCounts;
|
||||
known_channels_active: ContactActivityCounts;
|
||||
path_hash_width_24h: {
|
||||
total_packets: number;
|
||||
single_byte: number;
|
||||
|
||||
@@ -21,7 +21,12 @@ export interface UseWebSocketOptions {
|
||||
onChannel?: (channel: Channel) => void;
|
||||
onChannelDeleted?: (key: string) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
onMessageAcked?: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
onError?: (error: ErrorEvent) => void;
|
||||
onSuccess?: (success: SuccessEvent) => void;
|
||||
onReconnect?: () => void;
|
||||
@@ -128,8 +133,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
message_id: number;
|
||||
ack_count: number;
|
||||
paths?: MessagePath[];
|
||||
packet_id?: number | null;
|
||||
};
|
||||
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
|
||||
handlers.onMessageAcked?.(
|
||||
ackData.message_id,
|
||||
ackData.ack_count,
|
||||
ackData.paths,
|
||||
ackData.packet_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface MessageAckedPayload {
|
||||
message_id: number;
|
||||
ack_count: number;
|
||||
paths?: MessagePath[];
|
||||
packet_id?: number | null;
|
||||
}
|
||||
|
||||
export interface ContactDeletedPayload {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.3.1",
|
||||
"meshcore==2.3.2",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.7",
|
||||
"boto3>=1.38.0",
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface HealthStatus {
|
||||
radio_connected: boolean;
|
||||
radio_initializing: boolean;
|
||||
connection_info: string | null;
|
||||
bots_disabled?: boolean;
|
||||
bots_disabled_source?: 'env' | 'until_restart' | null;
|
||||
basic_auth_enabled?: boolean;
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
|
||||
@@ -12,8 +12,8 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
// Don't retry — failures likely indicate real hardware/app issues
|
||||
retries: 0,
|
||||
// Give hardware-backed flows one automatic retry before marking the test failed.
|
||||
retries: 1,
|
||||
|
||||
// Run tests serially — single radio means no parallelism
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
|
||||
"""Tests for bot-disable enforcement.
|
||||
|
||||
Verifies that when disable_bots=True:
|
||||
- POST /api/fanout with type=bot returns 403
|
||||
- Health endpoint includes bots_disabled=True
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import Settings
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
||||
from app.routers.health import build_health_data
|
||||
|
||||
@@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_create_returns_403_when_disabled(self, test_db):
|
||||
"""POST /api/fanout with type=bot returns 403."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
@@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
|
||||
"""Non-bot fanout configs can still be created when bots are disabled."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
# Create as disabled so fanout_manager.reload_config is not called
|
||||
result = await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
@@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint:
|
||||
)
|
||||
assert result["type"] == "mqtt_private"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source",
|
||||
return_value="until_restart",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "until the server restarts" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_bots_until_restart_endpoint(self, test_db):
|
||||
from app.routers.fanout import disable_bots_until_restart
|
||||
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
scope={"messages": "all", "raw_packets": "none"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.routers.fanout.fanout_manager.disable_bots_until_restart",
|
||||
new=AsyncMock(return_value="until_restart"),
|
||||
) as mock_disable,
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime,
|
||||
):
|
||||
mock_radio_runtime.is_connected = True
|
||||
mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000"
|
||||
|
||||
result = await disable_bots_until_restart()
|
||||
|
||||
mock_disable.assert_awaited_once()
|
||||
mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000")
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": "until_restart",
|
||||
}
|
||||
|
||||
|
||||
class TestDisableBotsHealthEndpoint:
|
||||
"""Test that bots_disabled is exposed in health data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_bots_disabled_true(self, test_db):
|
||||
with patch("app.routers.health.settings", MagicMock(disable_bots=True, database_path="x")):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
@@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_bots_disabled_false(self, test_db):
|
||||
with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["bots_disabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_basic_auth_enabled(self, test_db):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=True, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["basic_auth_enabled"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_runtime_bot_disable_source(self, test_db):
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
|
||||
),
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
||||
):
|
||||
mock_fm.get_statuses.return_value = {}
|
||||
mock_fm.get_bots_disabled_source.return_value = "until_restart"
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["bots_disabled"] is True
|
||||
assert data["bots_disabled_source"] == "until_restart"
|
||||
|
||||
@@ -1131,7 +1131,7 @@ class TestMessageAckedBroadcastShape:
|
||||
# Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117)
|
||||
# The 'paths' key is optional in the TypeScript interface
|
||||
REQUIRED_KEYS = {"message_id", "ack_count"}
|
||||
OPTIONAL_KEYS = {"paths"}
|
||||
OPTIONAL_KEYS = {"paths", "packet_id"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts):
|
||||
@@ -1177,6 +1177,7 @@ class TestMessageAckedBroadcastShape:
|
||||
assert isinstance(payload["ack_count"], int)
|
||||
assert payload["message_id"] == msg_id
|
||||
assert payload["ack_count"] == 1
|
||||
assert payload["packet_id"] == pkt_id
|
||||
|
||||
# paths should be a list of dicts with path and received_at keys
|
||||
assert isinstance(payload["paths"], list)
|
||||
@@ -1228,6 +1229,7 @@ class TestMessageAckedBroadcastShape:
|
||||
assert payload_keys >= self.REQUIRED_KEYS
|
||||
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
|
||||
assert payload["ack_count"] == 0 # Not outgoing, no ack increment
|
||||
assert payload["packet_id"] == pkt1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts):
|
||||
@@ -1283,3 +1285,4 @@ class TestMessageAckedBroadcastShape:
|
||||
assert isinstance(payload["message_id"], int)
|
||||
assert isinstance(payload["ack_count"], int)
|
||||
assert payload["ack_count"] == 0 # Outgoing DM duplicates no longer count as delivery
|
||||
assert payload["packet_id"] == pkt1
|
||||
|
||||
@@ -384,6 +384,7 @@ class TestContactMessageCLIFiltering:
|
||||
"acked",
|
||||
"sender_name",
|
||||
"channel_name",
|
||||
"packet_id",
|
||||
}
|
||||
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
@@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard:
|
||||
)
|
||||
|
||||
# Now try to update with bots disabled
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_fanout_config(
|
||||
cfg["id"],
|
||||
@@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard:
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock):
|
||||
result = await update_fanout_config(
|
||||
cfg["id"],
|
||||
|
||||
@@ -86,6 +86,16 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
assert "index page" in missing_response.text
|
||||
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
missing_api_response = client.get("/api/not-a-real-endpoint")
|
||||
assert missing_api_response.status_code == 404
|
||||
assert missing_api_response.json() == {
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a frontend "
|
||||
"request, you may be running a newer frontend with an older backend or vice "
|
||||
"versa. A full update is suggested."
|
||||
)
|
||||
}
|
||||
|
||||
asset_response = client.get("/assets/app.js")
|
||||
assert asset_response.status_code == 200
|
||||
assert "console.log('ok');" in asset_response.text
|
||||
|
||||
@@ -56,6 +56,48 @@ class TestUndecryptedCount:
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
|
||||
class TestGetRawPacket:
|
||||
"""Test GET /api/packets/{id}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_when_missing(self, test_db, client):
|
||||
response = await client.get("/api/packets/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_linked_packet_details(self, test_db, client):
|
||||
channel_key = "DEADBEEF" * 4
|
||||
await ChannelRepository.upsert(key=channel_key, name="#ops", is_hashtag=False)
|
||||
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test-packet", 1700000000)
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: hello",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=1700000000,
|
||||
received_at=1700000000,
|
||||
sender_name="Alice",
|
||||
)
|
||||
assert msg_id is not None
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
response = await client.get(f"/api/packets/{packet_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == packet_id
|
||||
assert data["timestamp"] == 1700000000
|
||||
assert data["data"] == "0900746573742d7061636b6574"
|
||||
assert data["decrypted"] is True
|
||||
assert data["decrypted_info"] == {
|
||||
"channel_name": "#ops",
|
||||
"sender": "Alice",
|
||||
"channel_key": channel_key,
|
||||
"contact_key": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDecryptHistoricalPackets:
|
||||
"""Test POST /api/packets/decrypt/historical."""
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class TestStatisticsEmpty:
|
||||
assert result["repeaters_heard"]["last_hour"] == 0
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 0
|
||||
assert result["repeaters_heard"]["last_week"] == 0
|
||||
assert result["known_channels_active"]["last_hour"] == 0
|
||||
assert result["known_channels_active"]["last_24_hours"] == 0
|
||||
assert result["known_channels_active"]["last_week"] == 0
|
||||
assert result["path_hash_width_24h"] == {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
@@ -256,6 +259,51 @@ class TestActivityWindows:
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 1
|
||||
assert result["repeaters_heard"]["last_week"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_channels_active_windows(self, test_db):
|
||||
"""Known channels are counted by distinct active keys in each time window."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
known_1h = "AA" * 16
|
||||
known_24h = "BB" * 16
|
||||
known_7d = "CC" * 16
|
||||
unknown_key = "DD" * 16
|
||||
|
||||
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_1h, "chan-1h"))
|
||||
await conn.execute(
|
||||
"INSERT INTO channels (key, name) VALUES (?, ?)", (known_24h, "chan-24h")
|
||||
)
|
||||
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_7d, "chan-7d"))
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_1h, "recent-1", now - 1200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_1h, "recent-2", now - 600),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_24h, "day-old", now - 43200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_7d, "week-old", now - 259200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", unknown_key, "unknown", now - 600),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["known_channels_active"]["last_hour"] == 1
|
||||
assert result["known_channels_active"]["last_24_hours"] == 2
|
||||
assert result["known_channels_active"]["last_week"] == 3
|
||||
|
||||
|
||||
class TestPathHashWidthStats:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -534,7 +534,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meshcore"
|
||||
version = "2.3.1"
|
||||
version = "2.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
@@ -542,9 +542,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyserial-asyncio-fast" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/a8/79f84f32cad056358b1e31dbb343d7f986f78fd93021dbbde306a9b4d36e/meshcore-2.3.1.tar.gz", hash = "sha256:07bd2267cb84a335b915ea6dab1601ae7ae13cad5923793e66b2356c3e351e24", size = 69503 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/df/66d615298b717c2c6471592e2b96117f391ae3c99f477d7f424449897bf0/meshcore-2.3.1-py3-none-any.whl", hash = "sha256:59bb8b66fd9e3261dbdb0e69fc038d4606bfd4ad1a260bbdd8659066e4bf12d2", size = 53084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1098,7 +1098,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomqtt" },
|
||||
@@ -1142,7 +1142,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
|
||||
{ name = "meshcore", specifier = "==2.3.1" },
|
||||
{ name = "meshcore", specifier = "==2.3.2" },
|
||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
|
||||
Reference in New Issue
Block a user