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:
agessaman
2026-09-15 22:34:46 -07:00
parent 681f0dcdcc
commit bb3bfe321b
19 changed files with 2870 additions and 1 deletions
+106
View File
@@ -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.