data: hide channels that have been flag for ignoring (#548)

* data: hide channels that have been flag for ignoring

* data: address review comments
This commit is contained in:
l5y
2025-12-14 16:47:44 +01:00
committed by GitHub
parent 96b62d7e14
commit 0e211aebdd
9 changed files with 176 additions and 6 deletions
+1
View File
@@ -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. |
+5 -1
View File
@@ -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
+9
View File
@@ -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"
+1
View File
@@ -52,6 +52,7 @@ _CONFIG_ATTRS = {
"DEBUG",
"INSTANCE",
"API_TOKEN",
"HIDDEN_CHANNELS",
"LORA_FREQ",
"MODEM_PRESET",
"_RECONNECT_INITIAL_DELAY_SECS",
+23
View File
@@ -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",
]
+35
View File
@@ -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",
+15 -5
View File
@@ -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),
+1
View File
@@ -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}
+86
View File
@@ -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 = []