diff --git a/DOCKER.md b/DOCKER.md index b3d72b8..6ef5f08 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`. | +| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; other channels are skipped before hidden filters. | | `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`. | diff --git a/README.md b/README.md index b57c149..576970f 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,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. | +| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; when set, all other channels are skipped before hidden filters. | | `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. | @@ -203,9 +204,10 @@ specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set 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. 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`. +ingestion limited, set `ALLOWED_CHANNELS` to a comma-separated whitelist (for +example `ALLOWED_CHANNELS="Chat,Ops"`); packets on other channels are discarded. +Use `HIDDEN_CHANNELS` to block specific channels from the web UI even when they +appear in the allowlist. ## Docker diff --git a/configure.sh b/configure.sh index 5ace804..aff888f 100755 --- a/configure.sh +++ b/configure.sh @@ -77,6 +77,7 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || 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 "") +ALLOWED_CHANNELS=$(grep "^ALLOWED_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") @@ -127,6 +128,9 @@ 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 whitelist of channel names to ingest (optional)." +echo "When set, only listed channels are ingested unless explicitly hidden below." +read_with_default "Allowed channels" "$ALLOWED_CHANNELS" ALLOWED_CHANNELS 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 @@ -199,6 +203,11 @@ update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG" update_env "FEDERATION" "$FEDERATION" update_env "PRIVATE" "$PRIVATE" update_env "CONNECTION" "$CONNECTION" +if [ -n "$ALLOWED_CHANNELS" ]; then + update_env "ALLOWED_CHANNELS" "\"$ALLOWED_CHANNELS\"" +else + sed -i.bak '/^ALLOWED_CHANNELS=.*/d' .env +fi if [ -n "$HIDDEN_CHANNELS" ]; then update_env "HIDDEN_CHANNELS" "\"$HIDDEN_CHANNELS\"" else @@ -252,6 +261,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 " Allowed Channels: ${ALLOWED_CHANNELS:-'All'}" echo " Hidden Channels: ${HIDDEN_CHANNELS:-'None'}" echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}" if [ "${FEDERATION:-1}" = "0" ]; then diff --git a/data/Dockerfile b/data/Dockerfile index 6542937..6630b8d 100644 --- a/data/Dockerfile +++ b/data/Dockerfile @@ -50,6 +50,8 @@ USER potatomesh ENV CONNECTION=/dev/ttyACM0 \ CHANNEL_INDEX=0 \ DEBUG=0 \ + ALLOWED_CHANNELS="" \ + HIDDEN_CHANNELS="" \ INSTANCE_DOMAIN="" \ API_TOKEN="" @@ -75,6 +77,8 @@ USER ContainerUser ENV CONNECTION=/dev/ttyACM0 \ CHANNEL_INDEX=0 \ DEBUG=0 \ + ALLOWED_CHANNELS="" \ + HIDDEN_CHANNELS="" \ INSTANCE_DOMAIN="" \ API_TOKEN="" diff --git a/data/mesh_ingestor/__init__.py b/data/mesh_ingestor/__init__.py index 694e756..9f7c68a 100644 --- a/data/mesh_ingestor/__init__.py +++ b/data/mesh_ingestor/__init__.py @@ -70,6 +70,7 @@ _CONFIG_ATTRS = { "DEBUG", "INSTANCE", "API_TOKEN", + "ALLOWED_CHANNELS", "HIDDEN_CHANNELS", "LORA_FREQ", "MODEM_PRESET", diff --git a/data/mesh_ingestor/channels.py b/data/mesh_ingestor/channels.py index afa8858..5575e33 100644 --- a/data/mesh_ingestor/channels.py +++ b/data/mesh_ingestor/channels.py @@ -228,6 +228,33 @@ def hidden_channel_names() -> tuple[str, ...]: return tuple(getattr(config, "HIDDEN_CHANNELS", ())) +def allowed_channel_names() -> tuple[str, ...]: + """Return the configured set of explicitly allowed channel names.""" + + return tuple(getattr(config, "ALLOWED_CHANNELS", ())) + + +def is_allowed_channel(channel_name_value: str | None) -> bool: + """Return ``True`` when ``channel_name_value`` is permitted by policy.""" + + allowed = getattr(config, "ALLOWED_CHANNELS", ()) + if not allowed: + return True + + if channel_name_value is None: + return False + + normalized = channel_name_value.strip() + if not normalized: + return False + + normalized_casefold = normalized.casefold() + for allowed_name in allowed: + if normalized_casefold == allowed_name.casefold(): + return True + return False + + def is_hidden_channel(channel_name_value: str | None) -> bool: """Return ``True`` when ``channel_name_value`` is configured as hidden.""" @@ -255,7 +282,9 @@ __all__ = [ "capture_from_interface", "channel_mappings", "channel_name", + "allowed_channel_names", "hidden_channel_names", + "is_allowed_channel", "is_hidden_channel", "_reset_channel_cache", ] diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 91a03c1..ff9eb33 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -66,8 +66,8 @@ 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. +def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]: + """Normalise a comma-separated list of channel names. Parameters: raw_value: Raw environment string containing channel names separated by @@ -96,9 +96,18 @@ def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]: return tuple(normalized_entries) +def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]: + """Compatibility wrapper that parses hidden channel names.""" + + return _parse_channel_names(raw_value) + + HIDDEN_CHANNELS = _parse_hidden_channels(os.environ.get("HIDDEN_CHANNELS")) """Channel names configured to be ignored by the ingestor.""" +ALLOWED_CHANNELS = _parse_channel_names(os.environ.get("ALLOWED_CHANNELS")) +"""Explicitly permitted channel names; when set, other channels are ignored.""" + def _resolve_instance_domain() -> str: """Resolve the configured instance domain from the environment. @@ -183,6 +192,7 @@ __all__ = [ "CHANNEL_INDEX", "DEBUG", "HIDDEN_CHANNELS", + "ALLOWED_CHANNELS", "INSTANCE", "API_TOKEN", "ENERGY_SAVING", diff --git a/data/mesh_ingestor/handlers.py b/data/mesh_ingestor/handlers.py index a21043f..0ba12f6 100644 --- a/data/mesh_ingestor/handlers.py +++ b/data/mesh_ingestor/handlers.py @@ -1461,6 +1461,18 @@ def store_packet_dict(packet: Mapping) -> None: _record_ignored_packet(packet, reason="skipped-direct-message") return + if not channels.is_allowed_channel(channel_name_value): + _record_ignored_packet(packet, reason="disallowed-channel") + if config.DEBUG: + config._debug_log( + "Ignored packet on disallowed channel", + context="handlers.store_packet_dict", + channel=channel, + channel_name=channel_name_value, + allowed_channels=channels.allowed_channel_names(), + ) + return + if channels.is_hidden_channel(channel_name_value): _record_ignored_packet(packet, reason="hidden-channel") if config.DEBUG: diff --git a/docker-compose.yml b/docker-compose.yml index 8ab5a5b..e3da6a6 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} + ALLOWED_CHANNELS: ${ALLOWED_CHANNELS:-""} HIDDEN_CHANNELS: ${HIDDEN_CHANNELS:-""} API_TOKEN: ${API_TOKEN} INSTANCE_DOMAIN: ${INSTANCE_DOMAIN} diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 173b0e5..e7ac5bd 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -285,6 +285,40 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch): mesh_module.INSTANCE = mesh_module.config.INSTANCE +def test_parse_channel_names_applies_allowlist(mesh_module): + """Ensure allowlists reuse the shared channel parser.""" + + mesh = mesh_module + previous_allowed = mesh.ALLOWED_CHANNELS + + try: + parsed = mesh.config._parse_channel_names(" Primary ,Chat ,primary , Ops ") + mesh.ALLOWED_CHANNELS = parsed + + assert parsed == ("Primary", "Chat", "Ops") + assert mesh.channels.allowed_channel_names() == ("Primary", "Chat", "Ops") + assert mesh.channels.is_allowed_channel("chat") + assert mesh.channels.is_allowed_channel(" ops ") + assert not mesh.channels.is_allowed_channel("unknown") + assert not mesh.channels.is_allowed_channel(None) + assert mesh.config._parse_channel_names("") == () + finally: + mesh.ALLOWED_CHANNELS = previous_allowed + + +def test_allowed_channel_defaults_allow_all(mesh_module): + """Ensure unset allowlists do not block any channels.""" + + mesh = mesh_module + previous_allowed = mesh.ALLOWED_CHANNELS + + try: + mesh.ALLOWED_CHANNELS = () + assert mesh.channels.is_allowed_channel("Any") + finally: + mesh.ALLOWED_CHANNELS = previous_allowed + + def test_parse_hidden_channels_deduplicates_names(mesh_module): """Ensure hidden channel parsing strips blanks and deduplicates.""" @@ -1997,8 +2031,10 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys previous_debug = mesh.config.DEBUG previous_hidden = mesh.HIDDEN_CHANNELS + previous_allowed = mesh.ALLOWED_CHANNELS mesh.config.DEBUG = True mesh.DEBUG = True + mesh.ALLOWED_CHANNELS = ("Chat",) mesh.HIDDEN_CHANNELS = ("Chat",) try: @@ -2017,6 +2053,77 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys assert ignored == ["hidden-channel"] assert "Ignored packet on hidden channel" in capsys.readouterr().out finally: + mesh.HIDDEN_CHANNELS = previous_hidden + mesh.ALLOWED_CHANNELS = previous_allowed + mesh.config.DEBUG = previous_debug + mesh.DEBUG = previous_debug + + +def test_store_packet_dict_skips_disallowed_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_allowed = mesh.ALLOWED_CHANNELS + previous_hidden = mesh.HIDDEN_CHANNELS + mesh.config.DEBUG = True + mesh.DEBUG = True + mesh.ALLOWED_CHANNELS = ("Primary",) + mesh.HIDDEN_CHANNELS = () + + try: + packet = { + "id": "1001", + "rxTime": 25_680, + "from": "!sender", + "to": "^all", + "channel": 5, + "decoded": {"text": "disallowed msg", "portnum": 1}, + } + + mesh.store_packet_dict(packet) + + assert captured == [] + assert ignored == ["disallowed-channel"] + assert "Ignored packet on disallowed channel" in capsys.readouterr().out + finally: + mesh.ALLOWED_CHANNELS = previous_allowed mesh.HIDDEN_CHANNELS = previous_hidden mesh.config.DEBUG = previous_debug mesh.DEBUG = previous_debug