feat(rnpath): implement the blackhole, RNPathHandler and integrate path management APIs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
62
meshchatx/src/backend/rnpath_handler.py
Normal file
62
meshchatx/src/backend/rnpath_handler.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user