mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-06 06:17:17 +00:00
feat(region): warn senders whose channel messages carry no region code
Closes #279. A MeshCore client with no region configured sends every channel message as a plain FLOOD, which every repeater on the mesh rebroadcasts. This adds two things: free observation of how much of that the bot hears, and an opt-in warning to the senders. Observation classifies each channel message as scoped, global or unknown and tallies it per channel per local day. It costs one upsert and no airtime, and it is what lets an operator see the size of the problem before deciding to spend airtime on it. The classification runs ahead of the flood_scopes allowlist, because an unscoped message is exactly what that allowlist drops. Warnings only ever fire on positive RF evidence of an unscoped FLOOD. Absence of correlation is not proof that a sender omitted a region, so it classifies as unknown and stays quiet. They are off by default, start in dry run, and are fenced by min_unscoped_messages, a per-sender cooldown, a mesh-wide cooldown and a daily cap on attempts. Dry run consumes the same budget it previews, so the log is what going live would put on the air, not an upper bound. All three limits read from the database, so a restart cannot release a burst. Channel-delivered warnings go out at global scope on purpose: the recipient is by definition outside any region the bot replies under. New Settings -> Region Warnings page in the web viewer covers all of it. Its dark-mode striped rows exposed a pre-existing base.html bug where Bootstrap's light-theme text color survived on a dark row background (~1.3:1); fixed there for every table in the app.
This commit is contained in:
@@ -17,6 +17,7 @@ from .graph_trace_helper import update_mesh_graph_from_trace_data
|
||||
from .meshcore_payload_decode import channel_hash_for_key, decrypt_group_text
|
||||
from .models import MeshMessage
|
||||
from .neighbors_discovery import upsert_zero_hop_observed_path_via_manager
|
||||
from .region_warning import VERDICT_GLOBAL, VERDICT_SCOPED, VERDICT_UNKNOWN
|
||||
from .security_utils import sanitize_input, sanitize_name
|
||||
from .utils import (
|
||||
calculate_packet_hash,
|
||||
@@ -318,6 +319,95 @@ class MessageHandler:
|
||||
return False
|
||||
return int(payload_type) == self._grp_txt_payload_type_int()
|
||||
|
||||
def _classify_channel_flood_scope(
|
||||
self,
|
||||
*,
|
||||
reply_scope: str | None,
|
||||
recent_rf_data: dict[str, Any] | None,
|
||||
packet_info: dict[str, Any] | None,
|
||||
scope_rf_data: dict[str, Any] | None,
|
||||
scope_packet_info: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""Classify a channel message's flood scope as scoped, global, or unknown.
|
||||
|
||||
"Scoped" means the message carried a region code (a TC_FLOOD transport
|
||||
code), whether or not that code matches one of ours. "Global" means it
|
||||
was proven to be an ordinary unscoped FLOOD. Anything else is unknown.
|
||||
|
||||
The scoped tests run first and the global test last, so every ambiguity
|
||||
resolves away from "global". That direction matters: ``global`` is the
|
||||
verdict that can spend airtime telling someone to fix their config, and
|
||||
a message whose scope the radio simply did not witness is not evidence
|
||||
that the sender omitted a region.
|
||||
"""
|
||||
if reply_scope:
|
||||
return VERDICT_SCOPED
|
||||
|
||||
# A correlated scope-eligible row *is* a TC_FLOOD GRP_TXT for this
|
||||
# message: it carried a transport code, so a region was set even though
|
||||
# it is not one this bot has keys for.
|
||||
if (
|
||||
scope_rf_data
|
||||
and rf_data_is_correlated(scope_rf_data)
|
||||
and self._is_rf_data_scope_eligible(scope_rf_data, scope_packet_info)
|
||||
):
|
||||
return VERDICT_SCOPED
|
||||
|
||||
if recent_rf_data and rf_data_is_correlated(recent_rf_data):
|
||||
route_type = self._effective_route_type_int(recent_rf_data, packet_info)
|
||||
if route_type == int(RouteType.TRANSPORT_FLOOD.value):
|
||||
return VERDICT_SCOPED
|
||||
|
||||
if self._is_confirmed_global_flood(
|
||||
recent_rf_data,
|
||||
packet_info,
|
||||
scoped_traffic_in_window=scope_rf_data is not None,
|
||||
):
|
||||
return VERDICT_GLOBAL
|
||||
|
||||
return VERDICT_UNKNOWN
|
||||
|
||||
async def _observe_flood_scope(
|
||||
self,
|
||||
*,
|
||||
sender_id: str | None,
|
||||
sender_pubkey: str | None,
|
||||
channel: str | None,
|
||||
sender_timestamp: Any,
|
||||
reply_scope: str | None,
|
||||
recent_rf_data: dict[str, Any] | None,
|
||||
packet_info: dict[str, Any] | None,
|
||||
scope_rf_data: dict[str, Any] | None,
|
||||
scope_packet_info: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""Hand this channel message's scope verdict to the region-warning monitor.
|
||||
|
||||
Messages the radio cached from before this connection are skipped: on a
|
||||
reconnect they arrive as a burst of old traffic, and counting them would
|
||||
both distort the tallies and let a stale message earn someone a warning.
|
||||
"""
|
||||
monitor = getattr(self.bot, "region_warning_monitor", None)
|
||||
if monitor is None:
|
||||
return
|
||||
if self._is_old_cached_message(sender_timestamp):
|
||||
return
|
||||
try:
|
||||
verdict = self._classify_channel_flood_scope(
|
||||
reply_scope=reply_scope,
|
||||
recent_rf_data=recent_rf_data,
|
||||
packet_info=packet_info,
|
||||
scope_rf_data=scope_rf_data,
|
||||
scope_packet_info=scope_packet_info,
|
||||
)
|
||||
await monitor.observe(
|
||||
verdict=verdict,
|
||||
sender_id=sender_id,
|
||||
sender_pubkey=sender_pubkey,
|
||||
channel=channel,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Flood scope observation failed")
|
||||
|
||||
def _is_old_cached_message(self, timestamp: Any) -> bool:
|
||||
"""Check if a message timestamp indicates it's from before bot connection.
|
||||
|
||||
@@ -2742,6 +2832,22 @@ class MessageHandler:
|
||||
"cannot authorise a reply under flood_scopes"
|
||||
)
|
||||
|
||||
# Region-code observation happens here, ahead of the flood_scopes
|
||||
# allowlist below, because an unscoped message is exactly what that
|
||||
# allowlist drops — running it after the gate would blind the
|
||||
# monitor to the traffic it exists to measure.
|
||||
await self._observe_flood_scope(
|
||||
sender_id=sender_id,
|
||||
sender_pubkey=payload.get("pubkey_prefix", ""),
|
||||
channel=channel_name,
|
||||
sender_timestamp=payload.get("sender_timestamp", 0),
|
||||
reply_scope=reply_scope,
|
||||
recent_rf_data=recent_rf_data,
|
||||
packet_info=packet_info,
|
||||
scope_rf_data=scope_rf_data,
|
||||
scope_packet_info=scope_packet_info,
|
||||
)
|
||||
|
||||
# Allowlist enforcement: when flood_scopes is configured, only reply to
|
||||
# messages whose scope matched an entry. Unscoped FLOOD is allowed only
|
||||
# when '*' (or equivalent) is explicitly listed.
|
||||
|
||||
Reference in New Issue
Block a user