mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
Rename COLLECTOR_LETSMESH_DECODER_KEYS to COLLECTOR_CHANNEL_KEYS
Simplify the variable name to remove the legacy LetsMesh decoder prefix. Also fix unparenthesized except tuples in web/app.py and promote the parenthesized-exception rule to a prominent position in AGENTS.md.
This commit is contained in:
+5
-1
@@ -192,7 +192,11 @@ PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=true
|
||||
# Note: Public + #test keys are built into the collector code by default.
|
||||
# To show friendly channel names in the web feed, use label=hex (example: bot=ABCDEF...).
|
||||
# Without keys, encrypted packets cannot be shown as plaintext.
|
||||
# COLLECTOR_LETSMESH_DECODER_KEYS=
|
||||
# COLLECTOR_CHANNEL_KEYS=
|
||||
|
||||
# Include built-in 'test' channel messages (channel_idx 217)
|
||||
# Default: false (test channel messages are discarded)
|
||||
# COLLECTOR_INCLUDE_TEST_CHANNEL=false
|
||||
|
||||
# -------------------
|
||||
# Webhook Settings
|
||||
|
||||
@@ -4,6 +4,9 @@ This document provides context and guidelines for AI coding assistants working o
|
||||
|
||||
## Agent Rules
|
||||
|
||||
### Critical Rules (MUST follow)
|
||||
|
||||
* **Always use parenthesized exception tuples** — `except (ValueError, TypeError):` not `except ValueError, TypeError:`. The comma form is Python 2 syntax and will fail at import time in Python 3. This is the most common error that passes visual review but breaks the application.
|
||||
* You MUST use Python (version in `.python-version` file)
|
||||
* You MUST activate a Python virtual environment in the `.venv` directory or create one if it does not exist:
|
||||
- `ls ./.venv` to check if it exists
|
||||
@@ -66,7 +69,6 @@ MeshCore Hub is a Python 3.14+ monorepo for managing and orchestrating MeshCore
|
||||
- Use type hints for all function signatures
|
||||
- Write docstrings for public modules, classes, and functions
|
||||
- Keep functions focused and under 50 lines where possible
|
||||
- **Always use parenthesized exception tuples** — `except (ValueError, TypeError):` not `except ValueError, TypeError:`. The comma form is Python 2 syntax and will fail at import time in Python 3
|
||||
|
||||
### Imports
|
||||
|
||||
@@ -598,6 +600,7 @@ Key variables:
|
||||
- `MQTT_WS_PATH` - WebSocket path (default: `/`)
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`, set `true` for `wss://`)
|
||||
- `MQTT_TOKEN_AUDIENCE` - JWT audience claim for packet capture auth tokens (default: `mqtt.localhost`)
|
||||
- `COLLECTOR_INCLUDE_TEST_CHANNEL` - Include built-in 'test' channel messages (default: `false`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `WEB_TRUSTED_PROXY_HOSTS` - Comma-separated list of trusted proxy hosts for admin authentication headers. Default: `*` (all hosts). Recommended: set to your reverse proxy IP in production. A startup warning is emitted when using the default `*` with admin enabled.
|
||||
|
||||
@@ -295,7 +295,8 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `COLLECTOR_LETSMESH_DECODER_KEYS` | *(none)* | Additional decoder channel keys (`label=hex`, `label:hex`, or `hex`) |
|
||||
| `COLLECTOR_CHANNEL_KEYS` | *(none)* | Additional decoder channel keys (`label=hex`, `label:hex`, or `hex`) |
|
||||
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Include built-in 'test' channel messages |
|
||||
|
||||
#### LetsMesh Packet Decoding
|
||||
|
||||
@@ -317,12 +318,12 @@ Normalization behavior:
|
||||
- `packet_type=1`, `2`, and `7` packets are mapped to `contact_msg_recv` when decryptable text is available.
|
||||
- For channel packets, if a channel key is available, a channel label is attached (for example `Public` or `#test`) for UI display.
|
||||
- In the messages feed and dashboard channel sections, known channel indexes are preferred for labels (`17 -> Public`, `217 -> #test`) to avoid stale channel-name mismatches.
|
||||
- Additional channel names are loaded from `COLLECTOR_LETSMESH_DECODER_KEYS` when entries are provided as `label=hex` (for example `bot=<key>`).
|
||||
- Additional channel names are loaded from `COLLECTOR_CHANNEL_KEYS` when entries are provided as `label=hex` (for example `bot=<key>`).
|
||||
- Decoder-advertisement packets with location metadata update node GPS (`lat/lon`) for map display.
|
||||
- This keeps advertisement listings focused on node advert traffic only, not observer status telemetry.
|
||||
- Packets without decryptable message text are kept as informational `letsmesh_packet` events and are not shown in the messages feed; when decode succeeds the decoded JSON is attached to those packet log events.
|
||||
- When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message` before storage; receiver/observer names are never used as sender fallback.
|
||||
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_LETSMESH_DECODER_KEYS`.
|
||||
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_CHANNEL_KEYS`.
|
||||
- Docker runtime uses the native Python `meshcoredecoder` library (no external Node.js dependency).
|
||||
|
||||
### Webhooks
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ Group/broadcast messages on specific channels.
|
||||
- In LetsMesh upload compatibility mode, packet type `5` is normalized to `CHANNEL_MSG_RECV` and packet types `1`, `2`, and `7` are normalized to `CONTACT_MSG_RECV` when decryptable text is available.
|
||||
- LetsMesh packets without decryptable message text are treated as informational `letsmesh_packet` events instead of message events.
|
||||
- For UI labels, known channel indexes are mapped (`17 -> Public`, `217 -> #test`) and preferred over ambiguous/stale channel-name hints.
|
||||
- Additional channel labels can be provided through `COLLECTOR_LETSMESH_DECODER_KEYS` using `label=hex` entries.
|
||||
- Additional channel labels can be provided through `COLLECTOR_CHANNEL_KEYS` using `label=hex` entries.
|
||||
- When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message`; sender identity remains unknown when only hash/prefix metadata is available.
|
||||
|
||||
**Compatibility ingest note (advertisements)**:
|
||||
|
||||
+2
-1
@@ -377,4 +377,5 @@ The Node.js `meshcore-decoder` CLI tool has been replaced by the native Python `
|
||||
- No Node.js runtime is needed in the Docker image
|
||||
- The decoder is always enabled (no toggle)
|
||||
- The `COLLECTOR_LETSMESH_DECODER_*` configuration variables have been removed
|
||||
- `COLLECTOR_LETSMESH_DECODER_KEYS` is still supported for providing additional channel decryption keys
|
||||
- `COLLECTOR_LETSMESH_DECODER_KEYS` has been renamed to `COLLECTOR_CHANNEL_KEYS`
|
||||
- New `COLLECTOR_INCLUDE_TEST_CHANNEL` variable controls whether built-in test channel messages are collected (default: `false`)
|
||||
|
||||
+4
-2
@@ -157,7 +157,8 @@ services:
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
|
||||
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
|
||||
- COLLECTOR_LETSMESH_DECODER_KEYS=${COLLECTOR_LETSMESH_DECODER_KEYS:-}
|
||||
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
||||
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
# Webhook configuration
|
||||
@@ -278,7 +279,8 @@ services:
|
||||
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
|
||||
- CONTENT_HOME=/content
|
||||
- TZ=${TZ:-UTC}
|
||||
- COLLECTOR_LETSMESH_DECODER_KEYS=${COLLECTOR_LETSMESH_DECODER_KEYS:-}
|
||||
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
||||
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
||||
# Feature flags (set to false to disable specific pages)
|
||||
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
|
||||
- FEATURE_NODES=${FEATURE_NODES:-true}
|
||||
|
||||
@@ -260,7 +260,7 @@ def _run_collector_service(
|
||||
|
||||
click.echo("")
|
||||
builtin_keys = len(LetsMeshPacketDecoder.BUILTIN_CHANNEL_KEYS)
|
||||
env_keys = len(settings.collector_letsmesh_decoder_keys_list)
|
||||
env_keys = len(settings.collector_channel_keys_list)
|
||||
click.echo(f"Packet decoder: {builtin_keys} built-in keys, {env_keys} from .env")
|
||||
|
||||
click.echo("")
|
||||
@@ -281,7 +281,8 @@ def _run_collector_service(
|
||||
cleanup_interval_hours=settings.data_retention_interval_hours,
|
||||
node_cleanup_enabled=settings.node_cleanup_enabled,
|
||||
node_cleanup_days=settings.node_cleanup_days,
|
||||
letsmesh_decoder_channel_keys=settings.collector_letsmesh_decoder_keys_list,
|
||||
channel_keys=settings.collector_channel_keys_list,
|
||||
include_test_channel=settings.collector_include_test_channel,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ class LetsMeshPacketDecoder:
|
||||
("test", "9CD8FCF22A47333B591D96A2B848B73F"),
|
||||
)
|
||||
|
||||
TEST_CHANNEL_HASH: str = "D9"
|
||||
TEST_CHANNEL_IDX: int = 217
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel_keys: list[str] | None = None,
|
||||
|
||||
@@ -18,6 +18,7 @@ class LetsMeshNormalizer:
|
||||
# Attributes are provided by Subscriber at runtime.
|
||||
mqtt: Any
|
||||
_letsmesh_decoder: LetsMeshPacketDecoder
|
||||
_include_test_channel: bool
|
||||
|
||||
def _normalize_letsmesh_event(
|
||||
self,
|
||||
@@ -100,6 +101,20 @@ class LetsMeshNormalizer:
|
||||
if decoded_packet is None:
|
||||
decoded_packet = self._letsmesh_decoder.decode_payload(payload)
|
||||
|
||||
# Filter test channel messages when not explicitly included.
|
||||
if packet_type == 5 and not self._include_test_channel:
|
||||
channel_hash = self._extract_letsmesh_decoder_channel_hash(decoded_packet)
|
||||
if (
|
||||
channel_hash
|
||||
and channel_hash.upper() == LetsMeshPacketDecoder.TEST_CHANNEL_HASH
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping LetsMesh packet %s (type=%s): test channel excluded",
|
||||
packet_hash_text or "unknown",
|
||||
packet_type,
|
||||
)
|
||||
return None
|
||||
|
||||
# In LetsMesh compatibility mode, only show messages that decrypt.
|
||||
text = self._extract_letsmesh_decoder_text(decoded_packet)
|
||||
if not text:
|
||||
|
||||
@@ -47,7 +47,8 @@ class Subscriber(LetsMeshNormalizer):
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
letsmesh_decoder_channel_keys: list[str] | None = None,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
):
|
||||
"""Initialize subscriber.
|
||||
|
||||
@@ -60,7 +61,8 @@ class Subscriber(LetsMeshNormalizer):
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
letsmesh_decoder_channel_keys: Optional channel keys for decrypting group text
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
"""
|
||||
self.mqtt = mqtt_client
|
||||
self.db = db_manager
|
||||
@@ -84,8 +86,9 @@ class Subscriber(LetsMeshNormalizer):
|
||||
self._cleanup_thread: Optional[threading.Thread] = None
|
||||
self._last_cleanup: Optional[datetime] = None
|
||||
self._letsmesh_decoder = LetsMeshPacketDecoder(
|
||||
channel_keys=letsmesh_decoder_channel_keys,
|
||||
channel_keys=channel_keys,
|
||||
)
|
||||
self._include_test_channel = include_test_channel
|
||||
|
||||
@property
|
||||
def is_healthy(self) -> bool:
|
||||
@@ -479,7 +482,8 @@ def create_subscriber(
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
letsmesh_decoder_channel_keys: list[str] | None = None,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
) -> Subscriber:
|
||||
"""Create a configured subscriber instance.
|
||||
|
||||
@@ -499,7 +503,8 @@ def create_subscriber(
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
letsmesh_decoder_channel_keys: Optional channel keys for decrypting group text
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
|
||||
Returns:
|
||||
Configured Subscriber instance
|
||||
@@ -532,7 +537,8 @@ def create_subscriber(
|
||||
cleanup_interval_hours=cleanup_interval_hours,
|
||||
node_cleanup_enabled=node_cleanup_enabled,
|
||||
node_cleanup_days=node_cleanup_days,
|
||||
letsmesh_decoder_channel_keys=letsmesh_decoder_channel_keys,
|
||||
channel_keys=channel_keys,
|
||||
include_test_channel=include_test_channel,
|
||||
)
|
||||
|
||||
# Register handlers
|
||||
@@ -559,7 +565,8 @@ def run_collector(
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
letsmesh_decoder_channel_keys: list[str] | None = None,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
) -> None:
|
||||
"""Run the collector (blocking).
|
||||
|
||||
@@ -579,7 +586,8 @@ def run_collector(
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
letsmesh_decoder_channel_keys: Optional channel keys for decrypting group text
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
"""
|
||||
subscriber = create_subscriber(
|
||||
mqtt_host=mqtt_host,
|
||||
@@ -597,7 +605,8 @@ def run_collector(
|
||||
cleanup_interval_hours=cleanup_interval_hours,
|
||||
node_cleanup_enabled=node_cleanup_enabled,
|
||||
node_cleanup_days=node_cleanup_days,
|
||||
letsmesh_decoder_channel_keys=letsmesh_decoder_channel_keys,
|
||||
channel_keys=channel_keys,
|
||||
include_test_channel=include_test_channel,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -138,13 +138,17 @@ class CollectorSettings(CommonSettings):
|
||||
description="Remove nodes not seen for this many days (last_seen)",
|
||||
ge=1,
|
||||
)
|
||||
collector_letsmesh_decoder_keys: Optional[str] = Field(
|
||||
collector_channel_keys: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional channel secret keys for LetsMesh message decryption. "
|
||||
"Optional channel secret keys for message decryption. "
|
||||
"Provide as comma/space separated hex values."
|
||||
),
|
||||
)
|
||||
collector_include_test_channel: bool = Field(
|
||||
default=False,
|
||||
description="Include built-in 'test' channel messages (channel_idx 217).",
|
||||
)
|
||||
|
||||
@property
|
||||
def collector_data_dir(self) -> str:
|
||||
@@ -185,13 +189,13 @@ class CollectorSettings(CommonSettings):
|
||||
return str(Path(self.effective_seed_home) / "members.yaml")
|
||||
|
||||
@property
|
||||
def collector_letsmesh_decoder_keys_list(self) -> list[str]:
|
||||
"""Parse configured LetsMesh decoder keys into a normalized list."""
|
||||
if not self.collector_letsmesh_decoder_keys:
|
||||
def collector_channel_keys_list(self) -> list[str]:
|
||||
"""Parse configured channel keys into a normalized list."""
|
||||
if not self.collector_channel_keys:
|
||||
return []
|
||||
return [
|
||||
part.strip()
|
||||
for part in re.split(r"[,\s]+", self.collector_letsmesh_decoder_keys)
|
||||
for part in re.split(r"[,\s]+", self.collector_channel_keys)
|
||||
if part.strip()
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ STATIC_DIR = PACKAGE_DIR / "static"
|
||||
|
||||
|
||||
def _parse_decoder_key_entries(raw: str | None) -> list[str]:
|
||||
"""Parse COLLECTOR_LETSMESH_DECODER_KEYS into key entries."""
|
||||
"""Parse COLLECTOR_CHANNEL_KEYS into key entries."""
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in re.split(r"[,\s]+", raw) if part.strip()]
|
||||
@@ -41,11 +41,18 @@ def _parse_decoder_key_entries(raw: str | None) -> list[str]:
|
||||
|
||||
def _build_channel_labels() -> dict[str, str]:
|
||||
"""Build UI channel labels from built-in + configured decoder keys."""
|
||||
raw_keys = os.getenv("COLLECTOR_LETSMESH_DECODER_KEYS")
|
||||
raw_keys = os.getenv("COLLECTOR_CHANNEL_KEYS")
|
||||
include_test = os.getenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
decoder = LetsMeshPacketDecoder(
|
||||
channel_keys=_parse_decoder_key_entries(raw_keys),
|
||||
)
|
||||
labels = decoder.channel_labels_by_index()
|
||||
if not include_test:
|
||||
labels.pop(LetsMeshPacketDecoder.TEST_CHANNEL_IDX, None)
|
||||
return {str(idx): label for idx, label in sorted(labels.items())}
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class _TestNormalizer(LetsMeshNormalizer):
|
||||
|
||||
def __init__(self, decoder: LetsMeshPacketDecoder) -> None:
|
||||
self._letsmesh_decoder = decoder
|
||||
self._include_test_channel = True
|
||||
self.mqtt = MagicMock()
|
||||
self.mqtt.topic_builder.parse_letsmesh_upload_topic = MagicMock(
|
||||
return_value=None,
|
||||
|
||||
@@ -402,6 +402,7 @@ class TestSubscriber:
|
||||
subscriber = Subscriber(
|
||||
mock_mqtt_client,
|
||||
db_manager,
|
||||
include_test_channel=True,
|
||||
)
|
||||
handler = MagicMock()
|
||||
subscriber.register_handler("channel_msg_recv", handler)
|
||||
|
||||
@@ -60,14 +60,14 @@ class TestCollectorSettings:
|
||||
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
|
||||
assert settings.members_file == "/seed/data/members.yaml"
|
||||
|
||||
def test_collector_letsmesh_decoder_keys_list(self) -> None:
|
||||
"""LetsMesh decoder keys are parsed from comma/space-separated env values."""
|
||||
def test_collector_channel_keys_list(self) -> None:
|
||||
"""Channel keys are parsed from comma/space-separated env values."""
|
||||
settings = CollectorSettings(
|
||||
_env_file=None,
|
||||
collector_letsmesh_decoder_keys="aa11, bb22 cc33",
|
||||
collector_channel_keys="aa11, bb22 cc33",
|
||||
)
|
||||
|
||||
assert settings.collector_letsmesh_decoder_keys_list == [
|
||||
assert settings.collector_channel_keys_list == [
|
||||
"aa11",
|
||||
"bb22",
|
||||
"cc33",
|
||||
|
||||
@@ -320,6 +320,7 @@ def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -
|
||||
"""Create a web app with mocked HTTP client."""
|
||||
# Ensure tests use a consistent locale regardless of local .env
|
||||
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
|
||||
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
|
||||
app = create_app(
|
||||
api_url="http://localhost:8000",
|
||||
api_key="test-api-key",
|
||||
|
||||
Reference in New Issue
Block a user