mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 19:03:28 +02:00
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:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -52,6 +52,7 @@ _CONFIG_ATTRS = {
|
||||
"DEBUG",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"HIDDEN_CHANNELS",
|
||||
"LORA_FREQ",
|
||||
"MODEM_PRESET",
|
||||
"_RECONNECT_INITIAL_DELAY_SECS",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user