From 0e211aebdd83b9d447836ff5e579cac70a57a61b Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:47:44 +0100 Subject: [PATCH] data: hide channels that have been flag for ignoring (#548) * data: hide channels that have been flag for ignoring * data: address review comments --- DOCKER.md | 1 + README.md | 6 ++- configure.sh | 9 ++++ data/mesh_ingestor/__init__.py | 1 + data/mesh_ingestor/channels.py | 23 +++++++++ data/mesh_ingestor/config.py | 35 ++++++++++++++ data/mesh_ingestor/handlers.py | 20 ++++++-- docker-compose.yml | 1 + tests/test_mesh.py | 86 ++++++++++++++++++++++++++++++++++ 9 files changed, 176 insertions(+), 6 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 5c78956..b3d72b8 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -53,6 +53,7 @@ Additional environment variables are optional: | `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). | | `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. | | `DEBUG` | `0` | Enables verbose logging across services when set to `1`. | +| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor skips when forwarding packets. | | `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). | | `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. | | `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. | diff --git a/README.md b/README.md index e45a6f5..035f304 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ The web app can be configured with environment variables (defaults shown): | `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. | | `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. | | `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. | +| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. | | `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. | | `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. | @@ -191,7 +192,10 @@ an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP interface. `CONNECTION` also accepts Bluetooth device addresses (e.g., `ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. The ingestor will still honor the legacy `POTATOMESH_INSTANCE` variable when -`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments. +`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments. To keep +private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated +list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on +those channels are discarded instead of being sent to `/api/messages`. ## Docker diff --git a/configure.sh b/configure.sh index 272ef9b..5ace804 100755 --- a/configure.sh +++ b/configure.sh @@ -76,6 +76,7 @@ CHANNEL=$(grep "^CHANNEL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "915MHz") FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1") PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0") +HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833") MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42") @@ -126,6 +127,8 @@ echo "-------------------" echo "Private mode hides public mesh messages from unauthenticated visitors." echo "Set to 1 to hide public feeds or 0 to keep them visible." read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE +echo "Provide a comma-separated list of channel names to hide from the web UI (optional)." +read_with_default "Hidden channels" "$HIDDEN_CHANNELS" HIDDEN_CHANNELS echo "" echo "🛠 Docker Settings" @@ -196,6 +199,11 @@ update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG" update_env "FEDERATION" "$FEDERATION" update_env "PRIVATE" "$PRIVATE" update_env "CONNECTION" "$CONNECTION" +if [ -n "$HIDDEN_CHANNELS" ]; then + update_env "HIDDEN_CHANNELS" "\"$HIDDEN_CHANNELS\"" +else + sed -i.bak '/^HIDDEN_CHANNELS=.*/d' .env +fi if [ -n "$INSTANCE_DOMAIN" ]; then update_env "INSTANCE_DOMAIN" "$INSTANCE_DOMAIN" else @@ -244,6 +252,7 @@ echo " API Token: ${API_TOKEN:0:8}..." echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH" echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG" echo " Private Mode: ${PRIVATE}" +echo " Hidden Channels: ${HIDDEN_CHANNELS:-'None'}" echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}" if [ "${FEDERATION:-1}" = "0" ]; then echo " Federation: Disabled" diff --git a/data/mesh_ingestor/__init__.py b/data/mesh_ingestor/__init__.py index f21bed9..881adf2 100644 --- a/data/mesh_ingestor/__init__.py +++ b/data/mesh_ingestor/__init__.py @@ -52,6 +52,7 @@ _CONFIG_ATTRS = { "DEBUG", "INSTANCE", "API_TOKEN", + "HIDDEN_CHANNELS", "LORA_FREQ", "MODEM_PRESET", "_RECONNECT_INITIAL_DELAY_SECS", diff --git a/data/mesh_ingestor/channels.py b/data/mesh_ingestor/channels.py index f0eb8fd..afa8858 100644 --- a/data/mesh_ingestor/channels.py +++ b/data/mesh_ingestor/channels.py @@ -222,6 +222,27 @@ def channel_name(channel_index: int | None) -> str | None: return _CHANNEL_LOOKUP.get(int(channel_index)) +def hidden_channel_names() -> tuple[str, ...]: + """Return the configured set of hidden channel names.""" + + return tuple(getattr(config, "HIDDEN_CHANNELS", ())) + + +def is_hidden_channel(channel_name_value: str | None) -> bool: + """Return ``True`` when ``channel_name_value`` is configured as hidden.""" + + if channel_name_value is None: + return False + normalized = channel_name_value.strip() + if not normalized: + return False + normalized_casefold = normalized.casefold() + for hidden in getattr(config, "HIDDEN_CHANNELS", ()): + if normalized_casefold == hidden.casefold(): + return True + return False + + def _reset_channel_cache() -> None: """Clear cached channel data. Intended for use in tests only.""" @@ -234,5 +255,7 @@ __all__ = [ "capture_from_interface", "channel_mappings", "channel_name", + "hidden_channel_names", + "is_hidden_channel", "_reset_channel_cache", ] diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 5124ac0..bea54c7 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -63,6 +63,40 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX))) DEBUG = os.environ.get("DEBUG") == "1" +def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]: + """Normalise a comma-separated list of hidden channel names. + + Parameters: + raw_value: Raw environment string containing channel names separated by + commas. ``None`` and empty segments are ignored. + + Returns: + A tuple of unique, non-empty channel names preserving input order while + deduplicating case-insensitively. + """ + + if not raw_value: + return () + + normalized_entries: list[str] = [] + seen: set[str] = set() + for part in raw_value.split(","): + name = part.strip() + if not name: + continue + key = name.casefold() + if key in seen: + continue + seen.add(key) + normalized_entries.append(name) + + return tuple(normalized_entries) + + +HIDDEN_CHANNELS = _parse_hidden_channels(os.environ.get("HIDDEN_CHANNELS")) +"""Channel names configured to be ignored by the ingestor.""" + + def _resolve_instance_domain() -> str: """Resolve the configured instance domain from the environment. @@ -144,6 +178,7 @@ __all__ = [ "SNAPSHOT_SECS", "CHANNEL_INDEX", "DEBUG", + "HIDDEN_CHANNELS", "INSTANCE", "API_TOKEN", "ENERGY_SAVING", diff --git a/data/mesh_ingestor/handlers.py b/data/mesh_ingestor/handlers.py index c2f8ecf..a21043f 100644 --- a/data/mesh_ingestor/handlers.py +++ b/data/mesh_ingestor/handlers.py @@ -1414,6 +1414,8 @@ def store_packet_dict(packet: Mapping) -> None: except Exception: channel = 0 + channel_name_value = channels.channel_name(channel) + pkt_id = _first(packet, "id", "packet_id", "packetId", default=None) if pkt_id is None: _record_ignored_packet(packet, reason="missing-packet-id") @@ -1459,6 +1461,17 @@ def store_packet_dict(packet: Mapping) -> None: _record_ignored_packet(packet, reason="skipped-direct-message") return + if channels.is_hidden_channel(channel_name_value): + _record_ignored_packet(packet, reason="hidden-channel") + if config.DEBUG: + config._debug_log( + "Ignored packet on hidden channel", + context="handlers.store_packet_dict", + channel=channel, + channel_name=channel_name_value, + ) + return + message_payload = { "id": int(pkt_id), "rx_time": rx_time, @@ -1476,11 +1489,8 @@ def store_packet_dict(packet: Mapping) -> None: "emoji": emoji, } - channel_name_value = None - if not encrypted_flag: - channel_name_value = channels.channel_name(channel) - if channel_name_value: - message_payload["channel_name"] = channel_name_value + if not encrypted_flag and channel_name_value: + message_payload["channel_name"] = channel_name_value _queue_post_json( "/api/messages", _apply_radio_metadata(message_payload), diff --git a/docker-compose.yml b/docker-compose.yml index d30503f..305e96e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,7 @@ x-ingestor-base: &ingestor-base environment: CONNECTION: ${CONNECTION:-/dev/ttyACM0} CHANNEL_INDEX: ${CHANNEL_INDEX:-0} + HIDDEN_CHANNELS: ${HIDDEN_CHANNELS:-""} API_TOKEN: ${API_TOKEN} INSTANCE_DOMAIN: ${INSTANCE_DOMAIN} POTATOMESH_INSTANCE: ${POTATOMESH_INSTANCE:-http://web:41447} diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 5aae212..785bd47 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -281,6 +281,25 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch): mesh_module.INSTANCE = mesh_module.config.INSTANCE +def test_parse_hidden_channels_deduplicates_names(mesh_module): + """Ensure hidden channel parsing strips blanks and deduplicates.""" + + mesh = mesh_module + previous_hidden = mesh.HIDDEN_CHANNELS + + try: + parsed = mesh.config._parse_hidden_channels(" Chat , ,Secret ,chat") + mesh.HIDDEN_CHANNELS = parsed + + assert parsed == ("Chat", "Secret") + assert mesh.channels.hidden_channel_names() == ("Chat", "Secret") + assert mesh.channels.is_hidden_channel(" chat ") + assert not mesh.channels.is_hidden_channel("unknown") + assert mesh.config._parse_hidden_channels("") == () + finally: + mesh.HIDDEN_CHANNELS = previous_hidden + + def test_subscribe_receive_topics_covers_all_handlers(mesh_module, monkeypatch): mesh = mesh_module daemon_mod = sys.modules["data.mesh_ingestor.daemon"] @@ -1932,6 +1951,73 @@ def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys assert "channel_display='Chat'" in log_output +def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys): + mesh = mesh_module + mesh.channels._reset_channel_cache() + mesh.config.MODEM_PRESET = None + + class DummyInterface: + def __init__(self) -> None: + self.localNode = SimpleNamespace( + channels=[ + SimpleNamespace( + role=1, + settings=SimpleNamespace(name="Primary"), + ), + SimpleNamespace( + role=2, + index=5, + settings=SimpleNamespace(name="Chat"), + ), + ] + ) + + def waitForConfig(self): + return None + + mesh.channels.capture_from_interface(DummyInterface()) + capsys.readouterr() + + captured: list[tuple[str, dict, int]] = [] + ignored: list[str] = [] + monkeypatch.setattr( + mesh, + "_queue_post_json", + lambda path, payload, *, priority: captured.append((path, payload, priority)), + ) + monkeypatch.setattr( + mesh.handlers, + "_record_ignored_packet", + lambda packet, *, reason: ignored.append(reason), + ) + + previous_debug = mesh.config.DEBUG + previous_hidden = mesh.HIDDEN_CHANNELS + mesh.config.DEBUG = True + mesh.DEBUG = True + mesh.HIDDEN_CHANNELS = ("Chat",) + + try: + packet = { + "id": "999", + "rxTime": 24_680, + "from": "!sender", + "to": "^all", + "channel": 5, + "decoded": {"text": "hidden msg", "portnum": 1}, + } + + mesh.store_packet_dict(packet) + + assert captured == [] + assert ignored == ["hidden-channel"] + assert "Ignored packet on hidden channel" in capsys.readouterr().out + finally: + mesh.HIDDEN_CHANNELS = previous_hidden + mesh.config.DEBUG = previous_debug + mesh.DEBUG = previous_debug + + def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch): mesh = mesh_module captured = []