From 63d81a02c9a3825f5a53ecbf26da12af4efea462 Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Sun, 4 Jan 2026 12:40:19 -0600 Subject: [PATCH] feat(rnpath): implement the blackhole, RNPathHandler and integrate path management APIs --- meshchatx/meshchat.py | 188 +++++++++++++++++- meshchatx/src/backend/config_manager.py | 7 + meshchatx/src/backend/identity_context.py | 4 + .../src/backend/persistent_log_handler.py | 53 ++++- meshchatx/src/backend/rnpath_handler.py | 62 ++++++ meshchatx/src/backend/rnstatus_handler.py | 17 ++ 6 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 meshchatx/src/backend/rnpath_handler.py diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 542c25b..1ba14c2 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -373,6 +373,15 @@ class ReticulumMeshChat: if self.current_context: self.current_context.rnstatus_handler = value + @property + def rnpath_handler(self): + return self.current_context.rnpath_handler if self.current_context else None + + @rnpath_handler.setter + def rnpath_handler(self, value): + if self.current_context: + self.current_context.rnpath_handler = value + @property def rnprobe_handler(self): return self.current_context.rnprobe_handler if self.current_context else None @@ -3976,6 +3985,11 @@ class ReticulumMeshChat: async def telephone_switch_audio_profile(request): profile_id = request.match_info.get("profile_id") try: + if self.telephone_manager.telephone is None: + return web.json_response( + {"message": "Telephone not initialized"}, status=400 + ) + await asyncio.to_thread( self.telephone_manager.telephone.switch_profile, int(profile_id), @@ -5522,6 +5536,75 @@ class ReticulumMeshChat: status=500, ) + @routes.get("/api/v1/rnpath/table") + async def rnpath_table(request): + max_hops = request.query.get("max_hops") + if max_hops: + max_hops = int(max_hops) + try: + table = self.rnpath_handler.get_path_table(max_hops=max_hops) + return web.json_response({"table": table}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.get("/api/v1/rnpath/rates") + async def rnpath_rates(request): + try: + rates = self.rnpath_handler.get_rate_table() + return web.json_response({"rates": rates}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.post("/api/v1/rnpath/drop") + async def rnpath_drop(request): + data = await request.json() + destination_hash = data.get("destination_hash") + if not destination_hash: + return web.json_response( + {"message": "destination_hash is required"}, status=400 + ) + try: + success = self.rnpath_handler.drop_path(destination_hash) + return web.json_response({"success": success}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.post("/api/v1/rnpath/drop-via") + async def rnpath_drop_via(request): + data = await request.json() + transport_instance_hash = data.get("transport_instance_hash") + if not transport_instance_hash: + return web.json_response( + {"message": "transport_instance_hash is required"}, status=400 + ) + try: + success = self.rnpath_handler.drop_all_via(transport_instance_hash) + return web.json_response({"success": success}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.post("/api/v1/rnpath/drop-queues") + async def rnpath_drop_queues(request): + try: + self.rnpath_handler.drop_announce_queues() + return web.json_response({"success": True}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.post("/api/v1/rnpath/request") + async def rnpath_request(request): + data = await request.json() + destination_hash = data.get("destination_hash") + if not destination_hash: + return web.json_response( + {"message": "destination_hash is required"}, status=400 + ) + try: + success = self.rnpath_handler.request_path(destination_hash) + return web.json_response({"success": success}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + @routes.post("/api/v1/rnprobe") async def rnprobe(request): data = await request.json() @@ -6461,12 +6544,46 @@ class ReticulumMeshChat: try: self.database.misc.add_blocked_destination(destination_hash) - # drop any existing paths to this destination - try: - if hasattr(self, "reticulum") and self.reticulum: - self.reticulum.drop_path(bytes.fromhex(destination_hash)) - except Exception as e: - print(f"Failed to drop path for blocked destination: {e}") + + # add to Reticulum blackhole if available and enabled + if self.config.blackhole_integration_enabled.get(): + try: + if hasattr(self, "reticulum") and self.reticulum: + # Try to resolve identity hash from destination hash + identity_hash = None + announce = self.database.announces.get_announce_by_hash( + destination_hash + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + + # Use resolved identity hash or fallback to destination hash + target_hash = identity_hash or destination_hash + dest_bytes = bytes.fromhex(target_hash) + + # Reticulum 1.1.0+ + if hasattr(self.reticulum, "blackhole_identity"): + reason = ( + f"Blocked in MeshChatX (from {destination_hash})" + if identity_hash + else "Blocked in MeshChatX" + ) + self.reticulum.blackhole_identity( + dest_bytes, reason=reason + ) + else: + # fallback to dropping path + self.reticulum.drop_path(dest_bytes) + except Exception as e: + print(f"Failed to blackhole identity in Reticulum: {e}") + else: + # fallback to just dropping path if integration disabled + try: + if hasattr(self, "reticulum") and self.reticulum: + self.reticulum.drop_path(bytes.fromhex(destination_hash)) + except Exception as e: + print(f"Failed to drop path for blocked destination: {e}") + return web.json_response({"message": "ok"}) except Exception: return web.json_response( @@ -6486,10 +6603,58 @@ class ReticulumMeshChat: try: self.database.misc.delete_blocked_destination(destination_hash) + + # remove from Reticulum blackhole if available and enabled + if self.config.blackhole_integration_enabled.get(): + try: + if hasattr(self, "reticulum") and self.reticulum: + # Try to resolve identity hash from destination hash + identity_hash = None + announce = self.database.announces.get_announce_by_hash( + destination_hash + ) + if announce and announce.get("identity_hash"): + identity_hash = announce["identity_hash"] + + # Use resolved identity hash or fallback to destination hash + target_hash = identity_hash or destination_hash + dest_bytes = bytes.fromhex(target_hash) + + if hasattr(self.reticulum, "unblackhole_identity"): + self.reticulum.unblackhole_identity(dest_bytes) + except Exception as e: + print(f"Failed to unblackhole identity in Reticulum: {e}") + return web.json_response({"message": "ok"}) except Exception as e: return web.json_response({"error": str(e)}, status=500) + @routes.get("/api/v1/reticulum/blackhole") + async def reticulum_blackhole_get(request): + if not hasattr(self, "reticulum") or not self.reticulum: + return web.json_response( + {"error": "Reticulum not initialized"}, status=503 + ) + + try: + if hasattr(self.reticulum, "get_blackholed_identities"): + identities = self.reticulum.get_blackholed_identities() + # Convert bytes keys to hex strings + formatted = {} + for h, info in identities.items(): + formatted[h.hex()] = { + "source": info.get("source", b"").hex() + if info.get("source") + else None, + "until": info.get("until"), + "reason": info.get("reason"), + } + return web.json_response({"blackholed_identities": formatted}) + else: + return web.json_response({"blackholed_identities": {}}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + # get spam keywords @routes.get("/api/v1/spam-keywords") async def spam_keywords_get(request): @@ -6871,8 +7036,10 @@ class ReticulumMeshChat: # Add security headers to all responses response.headers["X-Content-Type-Options"] = "nosniff" - # Allow framing for docs - if request.path.startswith("/reticulum-docs/"): + # Allow framing for docs and rnode flasher + if request.path.startswith("/reticulum-docs/") or request.path.startswith( + "/rnode-flasher/" + ): response.headers["X-Frame-Options"] = "SAMEORIGIN" else: response.headers["X-Frame-Options"] = "DENY" @@ -7354,6 +7521,10 @@ class ReticulumMeshChat: except Exception as e: print(f"Failed to update GPU disable flag: {e}") + if "blackhole_integration_enabled" in data: + value = self._parse_bool(data["blackhole_integration_enabled"]) + self.config.blackhole_integration_enabled.set(value) + # update voicemail settings if "voicemail_enabled" in data: self.config.voicemail_enabled.set( @@ -8234,6 +8405,7 @@ class ReticulumMeshChat: "libretranslate_url": ctx.config.libretranslate_url.get(), "desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(), "desktop_hardware_acceleration_enabled": ctx.config.desktop_hardware_acceleration_enabled.get(), + "blackhole_integration_enabled": ctx.config.blackhole_integration_enabled.get(), } # try and get a name for the provided identity hash diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 660336c..7163af3 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -236,6 +236,13 @@ class ConfigManager: ) self.message_font_size = self.IntConfig(self, "message_font_size", 14) + # blackhole integration config + self.blackhole_integration_enabled = self.BoolConfig( + self, + "blackhole_integration_enabled", + True, + ) + def get(self, key: str, default_value=None) -> str | None: return self.db.config.get(key, default_value) diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 3deb7e6..2d3c2d5 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -17,6 +17,7 @@ from meshchatx.src.backend.voicemail_manager import VoicemailManager from meshchatx.src.backend.ringtone_manager import RingtoneManager from meshchatx.src.backend.rncp_handler import RNCPHandler from meshchatx.src.backend.rnstatus_handler import RNStatusHandler +from meshchatx.src.backend.rnpath_handler import RNPathHandler from meshchatx.src.backend.rnprobe_handler import RNProbeHandler from meshchatx.src.backend.translator_handler import TranslatorHandler from meshchatx.src.backend.forwarding_manager import ForwardingManager @@ -195,6 +196,9 @@ class IdentityContext: self.rnstatus_handler = RNStatusHandler( reticulum_instance=getattr(self.app, "reticulum", None), ) + self.rnpath_handler = RNPathHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + ) self.rnprobe_handler = RNProbeHandler( reticulum_instance=getattr(self.app, "reticulum", None), identity=self.identity, diff --git a/meshchatx/src/backend/persistent_log_handler.py b/meshchatx/src/backend/persistent_log_handler.py index 76a0e55..61d3842 100644 --- a/meshchatx/src/backend/persistent_log_handler.py +++ b/meshchatx/src/backend/persistent_log_handler.py @@ -1,5 +1,6 @@ import collections import logging +import re import threading import time from datetime import UTC, datetime @@ -22,6 +23,10 @@ class PersistentLogHandler(logging.Handler): self.message_counts = collections.defaultdict(int) self.last_reset_time = time.time() + # UA and IP tracking + self.known_ips = set() + self.known_uas = set() + def set_database(self, database): with self.lock: self.database = database @@ -54,8 +59,54 @@ class PersistentLogHandler(logging.Handler): except Exception: self.handleError(record) + def _detect_access_anomaly(self, message): + """Detect anomalies in aiohttp access logs.""" + # Regex to extract IP and User-Agent from aiohttp access log + # Format: IP [date] "GET ..." status size "referer" "User-Agent" + match = re.search( + r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message + ) + if match: + ip = match.group(1) + ua = match.group(2) + + with self.lock: + is_anomaly = False + anomaly_type = None + + # Detect if this is a different UA or IP from what we've seen recently + if len(self.known_ips) > 0 and ip not in self.known_ips: + is_anomaly = True + anomaly_type = "multi_ip" + + if len(self.known_uas) > 0 and ua not in self.known_uas: + is_anomaly = True + if anomaly_type: + anomaly_type = "multi_ip_ua" + else: + anomaly_type = "multi_ua" + + self.known_ips.add(ip) + self.known_uas.add(ua) + + # Cap the tracking sets to prevent memory growth + if len(self.known_ips) > 100: + self.known_ips.clear() + if len(self.known_uas) > 100: + self.known_uas.clear() + + return is_anomaly, anomaly_type + + return False, None + def _detect_anomaly(self, record, message, timestamp): - # Only detect anomalies for WARNING level and above + # 1. Access anomaly detection (UA/IP) - checked for all levels of aiohttp.access + if record.name == "aiohttp.access": + is_acc_anomaly, acc_type = self._detect_access_anomaly(message) + if is_acc_anomaly: + return True, acc_type + + # Only detect other anomalies for WARNING level and above if record.levelno < logging.WARNING: return False, None diff --git a/meshchatx/src/backend/rnpath_handler.py b/meshchatx/src/backend/rnpath_handler.py new file mode 100644 index 0000000..92b54d5 --- /dev/null +++ b/meshchatx/src/backend/rnpath_handler.py @@ -0,0 +1,62 @@ +import RNS + + +class RNPathHandler: + def __init__(self, reticulum_instance: RNS.Reticulum): + self.reticulum = reticulum_instance + + def get_path_table(self, max_hops: int = None): + table = self.reticulum.get_path_table(max_hops=max_hops) + formatted_table = [] + for entry in table: + formatted_table.append( + { + "hash": entry["hash"].hex(), + "hops": entry["hops"], + "via": entry["via"].hex(), + "interface": entry["interface"], + "expires": entry["expires"], + } + ) + return sorted(formatted_table, key=lambda e: (e["interface"], e["hops"])) + + def get_rate_table(self): + table = self.reticulum.get_rate_table() + formatted_table = [] + for entry in table: + formatted_table.append( + { + "hash": entry["hash"].hex(), + "last": entry["last"], + "timestamps": entry["timestamps"], + "rate_violations": entry["rate_violations"], + "blocked_until": entry["blocked_until"], + } + ) + return sorted(formatted_table, key=lambda e: e["last"]) + + def drop_path(self, destination_hash: str) -> bool: + try: + dest_bytes = bytes.fromhex(destination_hash) + return self.reticulum.drop_path(dest_bytes) + except Exception: + return False + + def drop_all_via(self, transport_instance_hash: str) -> bool: + try: + ti_bytes = bytes.fromhex(transport_instance_hash) + return self.reticulum.drop_all_via(ti_bytes) + except Exception: + return False + + def drop_announce_queues(self): + self.reticulum.drop_announce_queues() + return True + + def request_path(self, destination_hash: str): + try: + dest_bytes = bytes.fromhex(destination_hash) + RNS.Transport.request_path(dest_bytes) + return True + except Exception: + return False diff --git a/meshchatx/src/backend/rnstatus_handler.py b/meshchatx/src/backend/rnstatus_handler.py index 58c65b3..50aa86c 100644 --- a/meshchatx/src/backend/rnstatus_handler.py +++ b/meshchatx/src/backend/rnstatus_handler.py @@ -1,5 +1,6 @@ import time from typing import Any +import RNS def size_str(num, suffix="B"): @@ -53,6 +54,19 @@ class RNStatusHandler: "link_count": link_count, } + blackhole_enabled = False + blackhole_sources = [] + blackhole_count = 0 + try: + blackhole_enabled = RNS.Reticulum.publish_blackhole_enabled() + blackhole_sources = [s.hex() for s in RNS.Reticulum.blackhole_sources()] + + # Get count of blackholed identities + if self.reticulum and hasattr(self.reticulum, "get_blackholed_identities"): + blackhole_count = len(self.reticulum.get_blackholed_identities()) + except Exception: + pass + interfaces = stats.get("interfaces", []) if sorting and isinstance(sorting, str): @@ -211,4 +225,7 @@ class RNStatusHandler: "interfaces": formatted_interfaces, "link_count": link_count, "timestamp": time.time(), + "blackhole_enabled": blackhole_enabled, + "blackhole_sources": blackhole_sources, + "blackhole_count": blackhole_count, }