mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
11198 lines
436 KiB
Python
11198 lines
436 KiB
Python
#!/usr/bin/env python
|
|
# SPDX-License-Identifier: 0BSD AND MIT
|
|
|
|
import argparse
|
|
import asyncio
|
|
import atexit
|
|
import base64
|
|
import binascii
|
|
import configparser
|
|
import contextlib
|
|
import copy
|
|
import fnmatch
|
|
import gc
|
|
import hashlib
|
|
import importlib
|
|
import importlib.metadata
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import runpy
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import sqlite3
|
|
import ssl
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import webbrowser
|
|
import zipfile
|
|
from datetime import UTC, datetime, timedelta
|
|
from logging.handlers import RotatingFileHandler
|
|
from typing import cast
|
|
from urllib.parse import urlparse
|
|
|
|
import aiohttp
|
|
import bcrypt
|
|
import LXMF
|
|
import psutil
|
|
import RNS
|
|
|
|
# meshchatx/__init__ already ensures pyogg ctypes aliases. Import LXST after
|
|
# that package init so plain pip installs do not crash without the Docker patch.
|
|
import LXST
|
|
from aiohttp import WSCloseCode, WSMessage, WSMsgType, web
|
|
from aiohttp_session import get_session
|
|
from aiohttp_session import setup as setup_session
|
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
|
from RNS.Discovery import InterfaceDiscovery
|
|
from serial.tools import list_ports
|
|
|
|
from meshchatx.android_push_bridge import (
|
|
_get_android_external_files_dir,
|
|
_is_chaquopy_android,
|
|
)
|
|
from meshchatx.src.backend import (
|
|
gif_utils,
|
|
i2p_support,
|
|
reticulum_pathfinding,
|
|
sticker_pack_utils,
|
|
)
|
|
from meshchatx.src.backend.announce_manager import (
|
|
filter_announced_dicts_by_search_query,
|
|
)
|
|
from meshchatx.src.backend.app_security_settings import (
|
|
get_trusted_proxy_cidrs,
|
|
get_web_ui_ip_allowlist,
|
|
load_app_security_settings,
|
|
save_app_security_settings,
|
|
)
|
|
from meshchatx.src.backend.async_utils import AsyncUtils
|
|
from meshchatx.src.backend.colour_utils import ColourUtils
|
|
from meshchatx.src.backend.csrf import (
|
|
ensure_session_csrf_token,
|
|
rotate_session_csrf_token,
|
|
validate_csrf_header,
|
|
)
|
|
from meshchatx.src.backend.altcha_auth import altcha_enabled_from_env
|
|
from meshchatx.src.backend.auth_page_hint import auth_page_hint_from_env
|
|
from meshchatx.src.backend.demo_mode import (
|
|
auth_bypass_from_env,
|
|
demo_auth_password_from_env,
|
|
)
|
|
from meshchatx.src.backend.database.access_attempts import (
|
|
LOGIN_PATH,
|
|
MAX_FAILED_BEFORE_LOCKOUT,
|
|
MAX_TRUSTED_LOGIN_PER_WINDOW,
|
|
MAX_UNTRUSTED_LOGIN_PER_WINDOW,
|
|
SETUP_PATH,
|
|
WINDOW_LOCKOUT_S,
|
|
WINDOW_RATE_TRUSTED_S,
|
|
WINDOW_RATE_UNTRUSTED_S,
|
|
user_agent_hash,
|
|
)
|
|
from meshchatx.src.backend.identity_context import IdentityContext
|
|
from meshchatx.src.backend.identity_manager import IdentityManager
|
|
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
|
|
from meshchatx.src.backend.interface_editor import InterfaceEditor
|
|
from meshchatx.src.backend.interface_port_check import (
|
|
describe_port_conflict,
|
|
is_port_in_use,
|
|
)
|
|
from meshchatx.src.backend.ip_allowlist import client_ip_allowed
|
|
from meshchatx.src.backend.landlock_sandbox import (
|
|
apply_landlock_sandbox,
|
|
extra_read_roots_from_app,
|
|
landlock_auto_enabled,
|
|
landlock_disabled_by_env,
|
|
landlock_kernel_supported,
|
|
landlock_requested,
|
|
)
|
|
from meshchatx.src.backend.appcontainer_sandbox import (
|
|
apply_windows_process_mitigations,
|
|
appcontainer_auto_enabled,
|
|
appcontainer_disabled_by_env,
|
|
appcontainer_requested,
|
|
appcontainer_supported,
|
|
is_appcontainer_child,
|
|
)
|
|
from meshchatx.src.backend.seccomp_sandbox import (
|
|
apply_seccomp_sandbox,
|
|
seccomp_auto_enabled,
|
|
seccomp_disabled_by_env,
|
|
seccomp_kernel_supported,
|
|
seccomp_requested,
|
|
)
|
|
from meshchatx.src.backend.legacy_migrator import (
|
|
assert_migration_context_paths,
|
|
fresh_storage_at_target,
|
|
migrate_legacy_to_target,
|
|
resolve_startup_storage,
|
|
)
|
|
from meshchatx.src.backend.lxmf_message_fields import (
|
|
LxmfAudioField,
|
|
LxmfFileAttachment,
|
|
LxmfFileAttachmentsField,
|
|
LxmfImageField,
|
|
)
|
|
from meshchatx.src.backend.lxmf_sieve import (
|
|
first_matching_lxmf_sieve_rule,
|
|
normalize_lxmf_sieve_filters,
|
|
parse_lxmf_sieve_filters_json,
|
|
)
|
|
from meshchatx.src.backend.lxmf_utils import (
|
|
FIELD_REACTION,
|
|
FIELD_REPLY_QUOTE,
|
|
FIELD_REPLY_TO,
|
|
LXMF_APP_EXTENSIONS_FIELD,
|
|
build_lxmf_reaction_field,
|
|
compute_lxmf_conversation_unread_from_latest_row,
|
|
convert_db_lxmf_message_to_dict,
|
|
convert_lxmf_message_to_dict,
|
|
convert_lxmf_method_to_string,
|
|
convert_lxmf_state_to_string,
|
|
is_lxmf_outbound_progress_terminal,
|
|
is_user_facing_lxmf_payload,
|
|
lxmf_fields_are_reaction,
|
|
lxmf_sidebar_preview_for_conversation_latest_row,
|
|
)
|
|
from meshchatx.src.backend.map_geo_validator import GeoValidationError
|
|
from meshchatx.src.backend.map_manager import (
|
|
MAX_EXPORT_TILES,
|
|
TRANSPARENT_TILE,
|
|
is_mbtiles_filename,
|
|
)
|
|
from meshchatx.src.backend.map_overlay_export import OverlayExportError
|
|
from meshchatx.src.backend.map_overlay_manager import (
|
|
CONFIG_CLAMPS,
|
|
clamp_overlay_config_value,
|
|
)
|
|
from meshchatx.src.backend.map_overlay_sources import OverlaySourceParseError
|
|
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
|
from meshchatx.src.backend.memory_pressure import (
|
|
MemoryPressureManager,
|
|
cache_stats,
|
|
prune_announce_timestamps,
|
|
prune_lxmf_incoming_timestamps,
|
|
)
|
|
from meshchatx.src.backend.meshchat_utils import (
|
|
cancel_inbound_deliveries,
|
|
convert_db_favourite_to_dict,
|
|
convert_propagation_node_state_to_string,
|
|
has_attachments,
|
|
hex_identifier_to_bytes,
|
|
interval_action_due,
|
|
list_inbound_deliveries,
|
|
message_fields_have_attachments,
|
|
normalize_hex_identifier,
|
|
normalize_identity_storage_hash,
|
|
parse_bool_query_param,
|
|
parse_lxmf_display_name,
|
|
parse_lxmf_propagation_node_app_data,
|
|
parse_lxmf_stamp_cost,
|
|
parse_nomadnetwork_node_display_name,
|
|
propagation_sync_idle_like,
|
|
propagation_sync_is_terminal,
|
|
)
|
|
from meshchatx.src.backend.message_blocklist import (
|
|
build_export_document as build_blocklist_export_document,
|
|
)
|
|
from meshchatx.src.backend.message_blocklist import (
|
|
first_matching_blocklist_entry,
|
|
normalize_message_blocklist,
|
|
parse_import_document,
|
|
parse_message_blocklist_json,
|
|
)
|
|
from meshchatx.src.backend.auto_resend_guard import (
|
|
AUTO_RESEND_COOLDOWN_SECONDS,
|
|
MAX_AUTO_RESEND_ATTEMPTS,
|
|
RECENT_SAME_CONTENT_SECONDS,
|
|
AutoResendCoordinator,
|
|
cooldown_until,
|
|
fields_have_attachments,
|
|
fields_with_auto_resend_count,
|
|
next_attempt_count,
|
|
parse_fields_dict,
|
|
should_skip_for_budget,
|
|
)
|
|
from meshchatx.src.backend.local_message_retention import (
|
|
purge_messages_before_cutoff,
|
|
resolve_message_age_cutoff,
|
|
)
|
|
from meshchatx.src.backend.message_export_bundle import (
|
|
build_messages_export_bundle,
|
|
import_messages_export_bundle,
|
|
)
|
|
from meshchatx.src.backend.nomadnet_downloader import (
|
|
NomadnetFileDownloader,
|
|
NomadnetPageDownloader,
|
|
clear_all_nomadnet_cached_links,
|
|
get_cached_active_link,
|
|
)
|
|
from meshchatx.src.backend.nomadnet_utils import (
|
|
convert_nomadnet_field_data_to_map,
|
|
convert_nomadnet_string_data_to_map,
|
|
)
|
|
from meshchatx.src.backend.page_node_manager import PageNodeManager
|
|
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
|
|
from meshchatx.src.backend.plugin_guard import PluginSecurityError
|
|
from meshchatx.src.backend.plugin_manager import PluginManager
|
|
from meshchatx.src.backend.active_sessions import (
|
|
ActiveSessionTracker,
|
|
should_warn_multi_session,
|
|
)
|
|
from meshchatx.src.backend.privacy_mode import (
|
|
OutboundHttpBlockedError,
|
|
ensure_outbound_http_allowed,
|
|
privacy_mode_enabled,
|
|
)
|
|
from meshchatx.src.backend.recovery import (
|
|
CrashRecovery,
|
|
HealthMonitor,
|
|
evaluate_startup_memory,
|
|
format_memory_log_line,
|
|
)
|
|
from meshchatx.src.backend.reticulum_config_guard import (
|
|
ensure_safe_reticulum_runtime_flags,
|
|
repair_unparseable_reticulum_config,
|
|
reticulum_config_has_required_sections,
|
|
)
|
|
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
|
|
from meshchatx.src.backend.rns_link_manager import (
|
|
RnsLinkManager,
|
|
clear_all_cached_links,
|
|
)
|
|
from meshchatx.src.backend.rns_startup_recovery import (
|
|
create_reticulum_with_recovery,
|
|
install_rns_panic_containment,
|
|
)
|
|
from meshchatx.src.backend.rrc import protocol as rrc_protocol
|
|
from meshchatx.src.backend.sideband_commands import SidebandCommands
|
|
from meshchatx.src.backend.sideband_plugin_loader import SidebandPluginLoader
|
|
from meshchatx.src.backend.sticker_utils import (
|
|
build_export_document,
|
|
detect_image_format_from_magic,
|
|
mime_for_image_type,
|
|
sanitize_sticker_emoji,
|
|
sanitize_sticker_name,
|
|
validate_export_document,
|
|
)
|
|
from meshchatx.src.backend.telemetry_utils import Telemeter
|
|
from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
|
|
from meshchatx.src.backend.websocket_config_guard import (
|
|
sanitize_websocket_config_update,
|
|
websocket_type_requires_auth,
|
|
)
|
|
from meshchatx.src.env_utils import env_bool
|
|
from meshchatx.src.path_utils import (
|
|
get_file_path,
|
|
is_path_within_dir,
|
|
resolve_log_dir,
|
|
resolve_meshchat_data_roots,
|
|
safe_path_under_dir,
|
|
)
|
|
from meshchatx.src.path_utils import (
|
|
request_client_ip as _request_client_ip,
|
|
)
|
|
from meshchatx.src.ssl_self_signed import generate_ssl_certificate
|
|
from meshchatx.src.version import __version__ as app_version
|
|
|
|
|
|
def _truncated_hash32_hex_ok(value: str | None) -> bool:
|
|
"""32 lowercase hex chars (Reticulum truncated hash) without relying on live RNS constants."""
|
|
return bool(normalize_identity_storage_hash(value))
|
|
|
|
|
|
# Global log handler
|
|
memory_log_handler = PersistentLogHandler()
|
|
log_dir = resolve_log_dir()
|
|
handlers = [memory_log_handler]
|
|
|
|
if log_dir:
|
|
file_handler = RotatingFileHandler(
|
|
os.path.join(log_dir, "meshchatx.log"),
|
|
maxBytes=5 * 1024 * 1024,
|
|
backupCount=3,
|
|
encoding="utf-8",
|
|
)
|
|
handlers.append(file_handler)
|
|
else:
|
|
handlers.append(logging.StreamHandler(sys.stdout))
|
|
|
|
logging.basicConfig(level=logging.INFO, handlers=handlers)
|
|
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
|
logger = logging.getLogger("meshchatx")
|
|
|
|
|
|
def _parse_rns_loglevel_value(raw: str | None) -> int | None:
|
|
if not raw or not str(raw).strip():
|
|
return None
|
|
raw = str(raw).strip().lower()
|
|
named = {
|
|
"none": RNS.LOG_NONE,
|
|
"critical": RNS.LOG_CRITICAL,
|
|
"error": RNS.LOG_ERROR,
|
|
"warning": RNS.LOG_WARNING,
|
|
"notice": RNS.LOG_NOTICE,
|
|
"verbose": RNS.LOG_VERBOSE,
|
|
"debug": RNS.LOG_DEBUG,
|
|
"extreme": RNS.LOG_EXTREME,
|
|
}
|
|
if raw in named:
|
|
return named[raw]
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _resolve_rns_loglevel(cli_override: str | None) -> int | None:
|
|
if cli_override is not None and str(cli_override).strip():
|
|
return _parse_rns_loglevel_value(cli_override)
|
|
return _parse_rns_loglevel_value(os.environ.get("MESHCHAT_RNS_LOG_LEVEL"))
|
|
|
|
|
|
def _restore_rns_console_logging_after_reticulum_init(app) -> None:
|
|
"""Undo shutdown side effects from RNS.Reticulum.exit_handler.
|
|
|
|
That handler sets RNS.loglevel to LOG_NONE and points sys.stdout /
|
|
sys.stderr at os.devnull. Without this, hot reload appears to stop all
|
|
announce traffic logging even though interfaces are up.
|
|
|
|
When no CLI or MESHCHAT_RNS_LOG_LEVEL value applies and the level is still
|
|
LOG_NONE after reading config, fall back to LOG_WARNING so notices are
|
|
visible. Explicit none in the environment remains respected.
|
|
"""
|
|
try:
|
|
if hasattr(sys, "__stdout__"):
|
|
sys.stdout = sys.__stdout__
|
|
if hasattr(sys, "__stderr__"):
|
|
sys.stderr = sys.__stderr__
|
|
except Exception:
|
|
pass
|
|
resolved = _resolve_rns_loglevel(getattr(app, "_rns_loglevel_cli", None))
|
|
if resolved is None and RNS.loglevel == RNS.LOG_NONE:
|
|
RNS.loglevel = RNS.LOG_WARNING
|
|
|
|
|
|
def _create_reticulum_instance(config_dir: str, loglevel: int | None = None):
|
|
"""Construct RNS.Reticulum even when called off the main thread.
|
|
|
|
Reticulum registers SIGINT/SIGTERM handlers in __init__. Python only allows
|
|
signal.signal on the main thread, so deferred network setup must skip that
|
|
registration when running in a background worker and install handlers later.
|
|
|
|
On failure, progressively disables risky interfaces (I2P, unsupported RNode,
|
|
AutoInterface, etc.) and retries so Android/desktop can recover without
|
|
wiping app data or the whole .reticulum tree. RNS.panic is contained
|
|
so it cannot os._exit the MeshChatX process.
|
|
"""
|
|
kwargs = {}
|
|
if loglevel is not None:
|
|
kwargs["loglevel"] = loglevel
|
|
|
|
def _construct():
|
|
if threading.current_thread() is threading.main_thread():
|
|
return RNS.Reticulum(config_dir, **kwargs)
|
|
|
|
real_signal = signal.signal
|
|
|
|
def _signal_allow_non_main(signum, handler):
|
|
try:
|
|
return real_signal(signum, handler)
|
|
except ValueError:
|
|
return signal.getsignal(signum)
|
|
|
|
signal.signal = _signal_allow_non_main
|
|
try:
|
|
return RNS.Reticulum(config_dir, **kwargs)
|
|
finally:
|
|
signal.signal = real_signal
|
|
|
|
return create_reticulum_with_recovery(
|
|
config_dir,
|
|
construct=_construct,
|
|
)
|
|
|
|
|
|
def _install_reticulum_signal_handlers() -> bool:
|
|
"""Install RNS SIGINT/SIGTERM handlers. Must run on the main thread."""
|
|
try:
|
|
signal.signal(signal.SIGINT, RNS.Reticulum.sigint_handler)
|
|
signal.signal(signal.SIGTERM, RNS.Reticulum.sigterm_handler)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def list_host_network_interfaces():
|
|
"""Enumerate kernel network interfaces on the host running MeshChat.
|
|
|
|
Uses psutil (Linux, macOS, Windows). Fails soft on restricted environments
|
|
(e.g. some Android sandboxes) and returns ([], error).
|
|
|
|
Reticulum's device field on server-style interfaces is a *single* interface
|
|
name, or omitted when binding only via listen_ip.
|
|
"""
|
|
try:
|
|
raw = psutil.net_if_addrs()
|
|
except Exception as exc:
|
|
logging.debug("list_host_network_interfaces: net_if_addrs failed: %s", exc)
|
|
return [], str(exc)
|
|
out: list[dict[str, object]] = []
|
|
for name in sorted(raw.keys(), key=lambda n: str(n).lower()):
|
|
addrs: list[str] = []
|
|
for addr in raw[name]:
|
|
if addr.family == socket.AF_INET:
|
|
addrs.append(addr.address)
|
|
elif addr.family == socket.AF_INET6:
|
|
if addr.address.startswith("fe80:"):
|
|
continue
|
|
addrs.append(addr.address)
|
|
out.append({"name": name, "addresses": addrs})
|
|
return out, None
|
|
|
|
|
|
def _is_loopback_bind_host(host: str | None) -> bool:
|
|
h = (host or "").strip().lower()
|
|
return h in ("127.0.0.1", "localhost", "::1", "[::1]")
|
|
|
|
|
|
def _csrf_exempt_path(path: str) -> bool:
|
|
return path == "/api/v1/auth/csrf"
|
|
|
|
|
|
# Live-name anchors for backend.http (meshchat_names / LiveMeshchatName).
|
|
_HTTP_LIVE_NAME_ANCHORS = (
|
|
binascii,
|
|
build_blocklist_export_document,
|
|
io,
|
|
platform,
|
|
sqlite3,
|
|
tempfile,
|
|
zipfile,
|
|
cast,
|
|
urlparse,
|
|
aiohttp,
|
|
bcrypt,
|
|
WSMessage,
|
|
WSMsgType,
|
|
InterfaceDiscovery,
|
|
list_ports,
|
|
gif_utils,
|
|
sticker_pack_utils,
|
|
filter_announced_dicts_by_search_query,
|
|
get_web_ui_ip_allowlist,
|
|
load_app_security_settings,
|
|
save_app_security_settings,
|
|
ensure_session_csrf_token,
|
|
rotate_session_csrf_token,
|
|
validate_csrf_header,
|
|
LOGIN_PATH,
|
|
SETUP_PATH,
|
|
InterfaceConfigParser,
|
|
describe_port_conflict,
|
|
is_port_in_use,
|
|
client_ip_allowed,
|
|
assert_migration_context_paths,
|
|
fresh_storage_at_target,
|
|
migrate_legacy_to_target,
|
|
normalize_lxmf_sieve_filters,
|
|
compute_lxmf_conversation_unread_from_latest_row,
|
|
convert_db_lxmf_message_to_dict,
|
|
is_user_facing_lxmf_payload,
|
|
lxmf_sidebar_preview_for_conversation_latest_row,
|
|
GeoValidationError,
|
|
MAX_EXPORT_TILES,
|
|
TRANSPARENT_TILE,
|
|
is_mbtiles_filename,
|
|
OverlayExportError,
|
|
OverlaySourceParseError,
|
|
MarkdownRenderer,
|
|
cache_stats,
|
|
cancel_inbound_deliveries,
|
|
convert_db_favourite_to_dict,
|
|
convert_propagation_node_state_to_string,
|
|
parse_bool_query_param,
|
|
parse_lxmf_propagation_node_app_data,
|
|
parse_lxmf_stamp_cost,
|
|
build_export_document,
|
|
normalize_message_blocklist,
|
|
parse_import_document,
|
|
purge_messages_before_cutoff,
|
|
resolve_message_age_cutoff,
|
|
build_messages_export_bundle,
|
|
import_messages_export_bundle,
|
|
NomadnetFileDownloader,
|
|
get_cached_active_link,
|
|
convert_nomadnet_field_data_to_map,
|
|
convert_nomadnet_string_data_to_map,
|
|
PluginSecurityError,
|
|
OutboundHttpBlockedError,
|
|
privacy_mode_enabled,
|
|
RNProbeHandler,
|
|
detect_image_format_from_magic,
|
|
mime_for_image_type,
|
|
sanitize_sticker_emoji,
|
|
sanitize_sticker_name,
|
|
validate_export_document,
|
|
sanitize_websocket_config_update,
|
|
websocket_type_requires_auth,
|
|
safe_path_under_dir,
|
|
)
|
|
|
|
|
|
class ReticulumMeshChat:
|
|
DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES = 3
|
|
|
|
def __init__(
|
|
self,
|
|
identity: RNS.Identity,
|
|
storage_dir,
|
|
reticulum_config_dir,
|
|
auto_recover: bool = False,
|
|
identity_file_path: str | None = None,
|
|
auth_enabled: bool = False,
|
|
public_dir: str | None = None,
|
|
emergency: bool = False,
|
|
gitea_base_url: str | None = None,
|
|
ssl_cert_path: str | None = None,
|
|
ssl_key_path: str | None = None,
|
|
rns_loglevel: str | None = None,
|
|
migration_context: dict | None = None,
|
|
memory_diag_enabled: bool = False,
|
|
plugins_enabled: bool = True,
|
|
defer_network_setup: bool = False,
|
|
headless: bool = False,
|
|
demo_mode: bool = False,
|
|
altcha_enabled: bool = False,
|
|
):
|
|
self.running = True
|
|
self.plugins_enabled = plugins_enabled
|
|
self.demo_mode = bool(demo_mode)
|
|
self.altcha_enabled = bool(altcha_enabled)
|
|
self.auth_page_hint = auth_page_hint_from_env()
|
|
self._memory_diag_enabled = memory_diag_enabled
|
|
self._mem_diag = None
|
|
self._headless = bool(headless)
|
|
self.migration_context = (
|
|
migration_context if migration_context is not None else {}
|
|
)
|
|
self.reticulum_config_dir = self._normalize_reticulum_config_dir(
|
|
reticulum_config_dir,
|
|
)
|
|
self.storage_dir = storage_dir or os.path.join("storage")
|
|
skip_storage_lock = os.environ.get(
|
|
"MESHCHAT_SKIP_STORAGE_LOCK",
|
|
"",
|
|
).lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
self._storage_lock = None
|
|
if not skip_storage_lock:
|
|
# Serializes startup, schema migration, and runtime for one storage_dir.
|
|
from meshchatx.src.backend.storage_lock import StorageLock, StorageLockError
|
|
|
|
self._storage_lock = StorageLock(self.storage_dir)
|
|
try:
|
|
self._storage_lock.acquire()
|
|
except StorageLockError as exc:
|
|
print(str(exc))
|
|
raise SystemExit(1) from exc
|
|
self.ssl_cert_path = ssl_cert_path
|
|
self.ssl_key_path = ssl_key_path
|
|
self.identity_file_path = identity_file_path
|
|
self.auto_recover = auto_recover
|
|
self.emergency = emergency
|
|
self.auth_enabled_initial = auth_enabled
|
|
self.public_dir_override = public_dir
|
|
self.gitea_base_url_override = gitea_base_url
|
|
self._rns_loglevel_cli = rns_loglevel
|
|
self.websocket_clients: list[web.WebSocketResponse] = []
|
|
self.active_sessions = ActiveSessionTracker()
|
|
self._websocket_broadcast_lock = asyncio.Lock()
|
|
self._identity_hotswap_lock = asyncio.Lock()
|
|
self.listen_host: str | None = None
|
|
self.listen_port: int | None = None
|
|
self.use_https: bool = True
|
|
self.landlock_active: bool = False
|
|
self.appcontainer_active: bool = False
|
|
self.seccomp_active: bool = False
|
|
self._pending_identity = identity
|
|
self._network_setup_lock = threading.Lock()
|
|
self._network_ready_event = threading.Event()
|
|
self._network_setup_thread: threading.Thread | None = None
|
|
self._startup_stage = "ready" if not defer_network_setup else "http"
|
|
self._startup_error: str | None = None
|
|
self._network_ready = not defer_network_setup
|
|
self._network_degraded = False
|
|
# HTTP can serve the shell immediately while RNS/identity finish.
|
|
self._ui_ready = True
|
|
self._rns_recovery_actions: list[str] = []
|
|
self._reticulum_secondary_started = False
|
|
|
|
# track announce timestamps for rate calculation (pruned to 1 hour / cap)
|
|
self.announce_timestamps = []
|
|
|
|
# track incoming lxmf message timestamps for flood protection
|
|
self._lxmf_incoming_timestamps = []
|
|
self._flood_protection_current_cost = None
|
|
self._flood_protection_last_bump_time = 0
|
|
|
|
# track download speeds for nomadnetwork files
|
|
self.download_speeds = []
|
|
|
|
# track active downloads
|
|
self.active_downloads = {}
|
|
self.download_id_counter = 0
|
|
|
|
self.identity_manager = IdentityManager(self.storage_dir, identity_file_path)
|
|
self.page_node_manager = PageNodeManager(
|
|
self.storage_dir,
|
|
on_announce=self._register_local_page_node_announce,
|
|
)
|
|
self.plugin_manager = PluginManager(self.storage_dir, app=self)
|
|
from meshchatx.src.backend.bug_report_manager import BugReportManager
|
|
|
|
self.bug_report_manager = BugReportManager(self)
|
|
self.sideband_plugin_loader = SidebandPluginLoader(self)
|
|
self._sideband_telemetry_thread = None
|
|
self._sideband_telemetry_running = False
|
|
|
|
# Multi-identity support
|
|
self.contexts: dict[str, IdentityContext] = {}
|
|
self.current_context: IdentityContext | None = None
|
|
self._propagation_sync_metrics: dict[str, dict] = {}
|
|
self._auto_resend_coordinator = AutoResendCoordinator()
|
|
|
|
AsyncUtils.ensure_background_loop()
|
|
self.web_audio_bridge = WebAudioBridge(
|
|
None,
|
|
None,
|
|
force_enabled=self.web_audio_required(),
|
|
)
|
|
self.rns_link_manager = RnsLinkManager(
|
|
self_identity_getter=lambda: self.identity,
|
|
reticulum_getter=lambda: getattr(self, "reticulum", None),
|
|
broadcast_event=self._on_rns_link_broadcast,
|
|
)
|
|
self.memory_pressure = MemoryPressureManager(app=self)
|
|
from meshchatx.src.backend.battery_usage_estimate import BatteryUsageTracker
|
|
|
|
self.battery_usage = BatteryUsageTracker()
|
|
try:
|
|
self._host_process = psutil.Process()
|
|
# Prime cpu_percent so later non-blocking samples are meaningful.
|
|
self._host_process.cpu_percent(interval=None)
|
|
except Exception:
|
|
self._host_process = None
|
|
# Track long-running rns.link.* handler tasks per WS client so they can
|
|
# be cancelled when the client disconnects.
|
|
self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
|
|
# Anchor RequestReceipts returned by link.request() for the lifetime of
|
|
# the request. Keyed by (client, request_id).
|
|
self._rns_request_receipts: dict = {}
|
|
if defer_network_setup:
|
|
self._set_startup_stage("http")
|
|
else:
|
|
self.setup_identity(identity)
|
|
self._mark_network_ready()
|
|
self._finish_deferred_startup_services()
|
|
|
|
def web_audio_required(self) -> bool:
|
|
"""True when LXST host audio is unusable and the browser bridge is mandatory.
|
|
|
|
Chaquopy Android has no usable LXST LineSource path. Docker/Alpine images
|
|
typically lack PulseAudio. Do not key this off --headless alone: frozen
|
|
Electron also uses headless (no auto browser) while still having host audio.
|
|
|
|
MESHCHAT_FORCE_WEB_AUDIO=1 forces the bridge for debugging or custom hosts.
|
|
"""
|
|
if _is_chaquopy_android():
|
|
return True
|
|
force = os.environ.get("MESHCHAT_FORCE_WEB_AUDIO", "").strip().lower()
|
|
if force in ("1", "true", "yes", "on"):
|
|
return True
|
|
cached = getattr(self, "_host_audio_unavailable_cached", None)
|
|
if cached is not None:
|
|
return cached
|
|
unavailable = self._probe_host_audio_unavailable()
|
|
self._host_audio_unavailable_cached = unavailable
|
|
return unavailable
|
|
|
|
@staticmethod
|
|
def _probe_host_audio_unavailable() -> bool:
|
|
"""Return True when LXST cannot open a host capture device."""
|
|
try:
|
|
from LXST.Sources import Backend
|
|
|
|
if Backend is None:
|
|
return True
|
|
Backend()
|
|
return False
|
|
except Exception:
|
|
return True
|
|
|
|
# Proxy properties for backward compatibility
|
|
@property
|
|
def identity(self):
|
|
return self.current_context.identity if self.current_context else None
|
|
|
|
@identity.setter
|
|
def identity(self, value):
|
|
if self.current_context:
|
|
self.current_context.identity = value
|
|
|
|
@property
|
|
def database(self):
|
|
return self.current_context.database if self.current_context else None
|
|
|
|
@database.setter
|
|
def database(self, value):
|
|
if self.current_context:
|
|
self.current_context.database = value
|
|
|
|
@property
|
|
def db(self):
|
|
return self.database
|
|
|
|
@db.setter
|
|
def db(self, value):
|
|
self.database = value
|
|
|
|
@property
|
|
def config(self):
|
|
return self.current_context.config if self.current_context else None
|
|
|
|
@config.setter
|
|
def config(self, value):
|
|
if self.current_context:
|
|
self.current_context.config = value
|
|
|
|
@property
|
|
def message_handler(self):
|
|
return self.current_context.message_handler if self.current_context else None
|
|
|
|
@message_handler.setter
|
|
def message_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.message_handler = value
|
|
|
|
@property
|
|
def announce_manager(self):
|
|
return self.current_context.announce_manager if self.current_context else None
|
|
|
|
@announce_manager.setter
|
|
def announce_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.announce_manager = value
|
|
|
|
@property
|
|
def archiver_manager(self):
|
|
return self.current_context.archiver_manager if self.current_context else None
|
|
|
|
@archiver_manager.setter
|
|
def archiver_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.archiver_manager = value
|
|
|
|
@property
|
|
def map_manager(self):
|
|
return self.current_context.map_manager if self.current_context else None
|
|
|
|
@map_manager.setter
|
|
def map_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.map_manager = value
|
|
|
|
@property
|
|
def map_overlay_manager(self):
|
|
return (
|
|
self.current_context.map_overlay_manager if self.current_context else None
|
|
)
|
|
|
|
@map_overlay_manager.setter
|
|
def map_overlay_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.map_overlay_manager = value
|
|
|
|
@property
|
|
def map_data_manager(self):
|
|
return self.current_context.map_data_manager if self.current_context else None
|
|
|
|
@map_data_manager.setter
|
|
def map_data_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.map_data_manager = value
|
|
|
|
@property
|
|
def docs_manager(self):
|
|
return self.current_context.docs_manager if self.current_context else None
|
|
|
|
@docs_manager.setter
|
|
def docs_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.docs_manager = value
|
|
|
|
@property
|
|
def repository_server_manager(self):
|
|
return (
|
|
self.current_context.repository_server_manager
|
|
if self.current_context
|
|
else None
|
|
)
|
|
|
|
@repository_server_manager.setter
|
|
def repository_server_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.repository_server_manager = value
|
|
|
|
@property
|
|
def nomadnet_manager(self):
|
|
return self.current_context.nomadnet_manager if self.current_context else None
|
|
|
|
@nomadnet_manager.setter
|
|
def nomadnet_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.nomadnet_manager = value
|
|
|
|
@property
|
|
def message_router(self):
|
|
return self.current_context.message_router if self.current_context else None
|
|
|
|
@message_router.setter
|
|
def message_router(self, value):
|
|
if self.current_context:
|
|
self.current_context.message_router = value
|
|
|
|
@property
|
|
def telephone_manager(self):
|
|
return self.current_context.telephone_manager if self.current_context else None
|
|
|
|
@telephone_manager.setter
|
|
def telephone_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.telephone_manager = value
|
|
|
|
@property
|
|
def voicemail_manager(self):
|
|
return self.current_context.voicemail_manager if self.current_context else None
|
|
|
|
@voicemail_manager.setter
|
|
def voicemail_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.voicemail_manager = value
|
|
|
|
@property
|
|
def ringtone_manager(self):
|
|
return self.current_context.ringtone_manager if self.current_context else None
|
|
|
|
@ringtone_manager.setter
|
|
def ringtone_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.ringtone_manager = value
|
|
|
|
@property
|
|
def notification_sound_manager(self):
|
|
return (
|
|
self.current_context.notification_sound_manager
|
|
if self.current_context
|
|
else None
|
|
)
|
|
|
|
@notification_sound_manager.setter
|
|
def notification_sound_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.notification_sound_manager = value
|
|
|
|
@property
|
|
def rncp_handler(self):
|
|
return self.current_context.rncp_handler if self.current_context else None
|
|
|
|
@rncp_handler.setter
|
|
def rncp_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.rncp_handler = value
|
|
|
|
@property
|
|
def rns_filesync_handler(self):
|
|
return (
|
|
self.current_context.rns_filesync_handler if self.current_context else None
|
|
)
|
|
|
|
@rns_filesync_handler.setter
|
|
def rns_filesync_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.rns_filesync_handler = value
|
|
|
|
@property
|
|
def rnsh_manager(self):
|
|
return self.current_context.rnsh_manager if self.current_context else None
|
|
|
|
@rnsh_manager.setter
|
|
def rnsh_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.rnsh_manager = value
|
|
|
|
@property
|
|
def rnx_manager(self):
|
|
return self.current_context.rnx_manager if self.current_context else None
|
|
|
|
@rnx_manager.setter
|
|
def rnx_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.rnx_manager = value
|
|
|
|
@property
|
|
def rnstatus_handler(self):
|
|
return self.current_context.rnstatus_handler if self.current_context else None
|
|
|
|
@rnstatus_handler.setter
|
|
def rnstatus_handler(self, value):
|
|
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 rnpath_trace_handler(self):
|
|
return (
|
|
self.current_context.rnpath_trace_handler if self.current_context else None
|
|
)
|
|
|
|
@rnpath_trace_handler.setter
|
|
def rnpath_trace_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.rnpath_trace_handler = value
|
|
|
|
@property
|
|
def rnprobe_handler(self):
|
|
return self.current_context.rnprobe_handler if self.current_context else None
|
|
|
|
@rnprobe_handler.setter
|
|
def rnprobe_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.rnprobe_handler = value
|
|
|
|
@property
|
|
def translator_handler(self):
|
|
return self.current_context.translator_handler if self.current_context else None
|
|
|
|
@translator_handler.setter
|
|
def translator_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.translator_handler = value
|
|
|
|
@property
|
|
def bot_handler(self):
|
|
return self.current_context.bot_handler if self.current_context else None
|
|
|
|
@bot_handler.setter
|
|
def bot_handler(self, value):
|
|
if self.current_context:
|
|
self.current_context.bot_handler = value
|
|
|
|
@property
|
|
def forwarding_manager(self):
|
|
return self.current_context.forwarding_manager if self.current_context else None
|
|
|
|
@forwarding_manager.setter
|
|
def forwarding_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.forwarding_manager = value
|
|
|
|
@property
|
|
def rrc_manager(self):
|
|
return self.current_context.rrc_manager if self.current_context else None
|
|
|
|
@rrc_manager.setter
|
|
def rrc_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.rrc_manager = value
|
|
|
|
@property
|
|
def rrc_server_manager(self):
|
|
return self.current_context.rrc_server_manager if self.current_context else None
|
|
|
|
@rrc_server_manager.setter
|
|
def rrc_server_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.rrc_server_manager = value
|
|
|
|
@property
|
|
def community_interfaces_manager(self):
|
|
return (
|
|
self.current_context.community_interfaces_manager
|
|
if self.current_context
|
|
else None
|
|
)
|
|
|
|
@community_interfaces_manager.setter
|
|
def community_interfaces_manager(self, value):
|
|
if self.current_context:
|
|
self.current_context.community_interfaces_manager = value
|
|
|
|
@property
|
|
def local_lxmf_destination(self):
|
|
return (
|
|
self.current_context.local_lxmf_destination
|
|
if self.current_context
|
|
else None
|
|
)
|
|
|
|
@local_lxmf_destination.setter
|
|
def local_lxmf_destination(self, value):
|
|
if self.current_context:
|
|
self.current_context.local_lxmf_destination = value
|
|
|
|
@property
|
|
def auth_enabled(self):
|
|
if auth_bypass_from_env():
|
|
return False
|
|
if self.config:
|
|
return self.config.auth_enabled.get()
|
|
return self.auth_enabled_initial
|
|
|
|
@property
|
|
def storage_path(self):
|
|
return (
|
|
self.current_context.storage_path
|
|
if self.current_context
|
|
else self.storage_dir
|
|
)
|
|
|
|
@storage_path.setter
|
|
def storage_path(self, value):
|
|
if self.current_context:
|
|
self.current_context.storage_path = value
|
|
|
|
def _check_bot_lifecycle(self) -> tuple[bool, str]:
|
|
"""Create, start, stop, and delete an Echo bot subprocess.
|
|
|
|
Uses an isolated identity + Reticulum config under storage so the check
|
|
does not touch user bots or ~/.reticulum.
|
|
"""
|
|
from meshchatx.src.backend.bot_handler import BotHandler
|
|
|
|
if not self.storage_path or not os.path.exists(self.storage_path):
|
|
return False, "Storage directory does not exist"
|
|
|
|
root = os.path.join(self.storage_path, ".self_test_bots")
|
|
identity_dir = os.path.join(root, "identity")
|
|
rns_dir = os.path.join(root, "reticulum")
|
|
bot_id = None
|
|
handler = None
|
|
try:
|
|
if os.path.isdir(root):
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
os.makedirs(identity_dir, exist_ok=True)
|
|
os.makedirs(rns_dir, exist_ok=True)
|
|
|
|
previous_rns = os.environ.get("MESHCHAT_BOT_RETICULUM_CONFIG_DIR")
|
|
os.environ["MESHCHAT_BOT_RETICULUM_CONFIG_DIR"] = rns_dir
|
|
try:
|
|
handler = BotHandler(identity_path=identity_dir)
|
|
bot_id = handler.start_bot("echo", "Self-Check Echo Bot")
|
|
finally:
|
|
if previous_rns is None:
|
|
os.environ.pop("MESHCHAT_BOT_RETICULUM_CONFIG_DIR", None)
|
|
else:
|
|
os.environ["MESHCHAT_BOT_RETICULUM_CONFIG_DIR"] = previous_rns
|
|
|
|
entry = next(
|
|
(e for e in handler.bots_state if e.get("id") == bot_id),
|
|
None,
|
|
)
|
|
if entry is None:
|
|
return False, "Bot entry missing after start"
|
|
|
|
pid = entry.get("pid")
|
|
if not pid:
|
|
return False, "Bot process did not report a pid"
|
|
|
|
deadline = time.monotonic() + 12.0
|
|
while time.monotonic() < deadline:
|
|
if not BotHandler._is_pid_alive(pid):
|
|
last_err = BotHandler._read_bot_last_error(entry.get("storage_dir"))
|
|
log_tail = ""
|
|
try:
|
|
log_info = handler.read_subprocess_log(bot_id, max_bytes=2048)
|
|
log_tail = (log_info.get("log") or "").strip()[-500:]
|
|
except Exception:
|
|
pass
|
|
detail = last_err or log_tail or "process exited early"
|
|
return False, f"Bot process died after start: {detail}"
|
|
# Confirm the launcher wrote something (script or run-module path).
|
|
try:
|
|
log_info = handler.read_subprocess_log(bot_id, max_bytes=1024)
|
|
if log_info.get("total_bytes", 0) > 0:
|
|
break
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.2)
|
|
else:
|
|
if not BotHandler._is_pid_alive(pid):
|
|
return False, "Bot process exited before becoming ready"
|
|
# Still alive with empty log is acceptable (buffered / slow start).
|
|
|
|
if not handler.stop_bot(bot_id):
|
|
return False, "stop_bot returned False"
|
|
stop_deadline = time.monotonic() + 8.0
|
|
while time.monotonic() < stop_deadline and BotHandler._is_pid_alive(pid):
|
|
time.sleep(0.1)
|
|
if BotHandler._is_pid_alive(pid):
|
|
return False, f"Bot pid {pid} still alive after stop"
|
|
|
|
storage_dir = entry.get("storage_dir")
|
|
if not handler.delete_bot(bot_id):
|
|
return False, "delete_bot returned False"
|
|
if any(e.get("id") == bot_id for e in handler.bots_state):
|
|
return False, "Bot still present in state after delete"
|
|
if storage_dir and os.path.exists(storage_dir):
|
|
return False, "Bot storage directory still exists after delete"
|
|
|
|
return True, ""
|
|
except Exception as e:
|
|
return False, f"Bot lifecycle check failed: {e}"
|
|
finally:
|
|
if handler is not None and bot_id is not None:
|
|
with contextlib.suppress(Exception):
|
|
handler.stop_bot(bot_id)
|
|
with contextlib.suppress(Exception):
|
|
handler.delete_bot(bot_id)
|
|
with contextlib.suppress(Exception):
|
|
if os.path.isdir(root):
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
|
|
def run_self_test(self) -> dict:
|
|
stack_ok = True
|
|
stack_reason = ""
|
|
if not hasattr(self, "reticulum") or self.reticulum is None:
|
|
stack_ok = False
|
|
stack_reason = "Reticulum stack is not initialized"
|
|
else:
|
|
try:
|
|
_ = self.reticulum.config
|
|
except Exception as e:
|
|
stack_ok = False
|
|
stack_reason = f"Reticulum stack internal error: {e!s}"
|
|
|
|
config_ok = True
|
|
config_reason = ""
|
|
try:
|
|
if self.config is None:
|
|
config_ok = False
|
|
config_reason = "App configuration is not initialized"
|
|
else:
|
|
_ = self.config.display_name.get()
|
|
except Exception as e:
|
|
config_ok = False
|
|
config_reason = f"App config error: {e!s}"
|
|
|
|
if config_ok:
|
|
try:
|
|
reticulum_config_path = self._api_reticulum_config_path()
|
|
if not reticulum_config_path or not os.path.exists(
|
|
reticulum_config_path,
|
|
):
|
|
config_ok = False
|
|
config_reason = "Reticulum config file not found"
|
|
elif not reticulum_config_has_required_sections(
|
|
reticulum_config_path,
|
|
):
|
|
config_ok = False
|
|
config_reason = "Reticulum config is missing required sections"
|
|
except Exception as e:
|
|
config_ok = False
|
|
config_reason = f"Reticulum config check failed: {e!s}"
|
|
|
|
db_ok = True
|
|
db_reason = ""
|
|
if not self.database:
|
|
db_ok = False
|
|
db_reason = "Database is not initialized"
|
|
else:
|
|
try:
|
|
self.database.execute_sql("SELECT 1").fetchone()
|
|
snapshot = self.database.get_database_health_snapshot()
|
|
if snapshot.get("quick_check") not in ("ok", "unknown"):
|
|
db_ok = False
|
|
db_reason = (
|
|
f"Database quick check returned: {snapshot.get('quick_check')}"
|
|
)
|
|
except Exception as e:
|
|
db_ok = False
|
|
db_reason = f"Database check failed: {e!s}"
|
|
|
|
rw_ok = True
|
|
rw_reason = ""
|
|
try:
|
|
if not self.storage_path or not os.path.exists(self.storage_path):
|
|
rw_ok = False
|
|
rw_reason = "Storage directory does not exist"
|
|
else:
|
|
temp_file_path = os.path.join(self.storage_path, ".self_test_temp")
|
|
test_data = "meshchatx_self_test_write_read_verify"
|
|
with open(temp_file_path, "w", encoding="utf-8") as f:
|
|
f.write(test_data)
|
|
|
|
with open(temp_file_path, encoding="utf-8") as f:
|
|
read_data = f.read()
|
|
|
|
if os.path.exists(temp_file_path):
|
|
os.remove(temp_file_path)
|
|
|
|
if read_data != test_data:
|
|
rw_ok = False
|
|
rw_reason = "Read data did not match written data"
|
|
except Exception as e:
|
|
rw_ok = False
|
|
rw_reason = f"Read/write test failed: {e!s}"
|
|
|
|
from meshchatx.src.backend import self_check as self_check_mod
|
|
|
|
bots_ok, bots_reason = self._check_bot_lifecycle()
|
|
identity_result = self_check_mod.check_identity(self.identity)
|
|
imports_result = self_check_mod.check_critical_imports()
|
|
# Fold Python runtime into imports so the UI stays one row.
|
|
runtime_result = self_check_mod.check_python_runtime()
|
|
if runtime_result["status"] != "ok" and imports_result["status"] == "ok":
|
|
imports_result = runtime_result
|
|
elif runtime_result["status"] != "ok":
|
|
imports_result = {
|
|
"status": "failed",
|
|
"reason": f"{imports_result.get('reason') or ''} | {runtime_result.get('reason') or ''}".strip(
|
|
" |",
|
|
),
|
|
}
|
|
storage_lock_result = self_check_mod.check_storage_lock(
|
|
self.storage_path or self.storage_dir,
|
|
)
|
|
temp_fs_result = self_check_mod.check_temp_filesystem()
|
|
fs_sandbox_result = self_check_mod.check_fs_sandbox()
|
|
public_assets_result = self_check_mod.check_public_assets(self.get_public_path)
|
|
lxmf_result = self_check_mod.check_lxmf_router(
|
|
self.message_router,
|
|
self.local_lxmf_destination,
|
|
)
|
|
subprocess_result = self_check_mod.check_subprocess_spawn()
|
|
run_module_result = self_check_mod.check_meshchatx_run_module()
|
|
storage_base = self.storage_path or self.storage_dir
|
|
sqlite_result = self_check_mod.check_sqlite_roundtrip(storage_base)
|
|
identity_file_result = self_check_mod.check_identity_file_roundtrip(
|
|
storage_base,
|
|
)
|
|
loopback_result = self_check_mod.check_loopback_tcp()
|
|
unicode_result = self_check_mod.check_unicode_path(storage_base)
|
|
rnode_result = self_check_mod.check_rnode_support()
|
|
bot_launcher_result = self_check_mod.check_bot_launcher()
|
|
plugins_runtime_result = self_check_mod.check_plugins_runtime(self)
|
|
web_results = self_check_mod.check_web_stack(self)
|
|
|
|
return {
|
|
"stack_up": {
|
|
"status": "ok" if stack_ok else "failed",
|
|
"reason": stack_reason,
|
|
},
|
|
"config_good": {
|
|
"status": "ok" if config_ok else "failed",
|
|
"reason": config_reason,
|
|
},
|
|
"db_good": {
|
|
"status": "ok" if db_ok else "failed",
|
|
"reason": db_reason,
|
|
},
|
|
"read_write_good": {
|
|
"status": "ok" if rw_ok else "failed",
|
|
"reason": rw_reason,
|
|
},
|
|
"identity_good": identity_result,
|
|
"imports_good": imports_result,
|
|
"storage_lock_good": storage_lock_result,
|
|
"temp_fs_good": temp_fs_result,
|
|
"fs_sandbox_good": fs_sandbox_result,
|
|
"public_assets_good": public_assets_result,
|
|
"lxmf_router_good": lxmf_result,
|
|
"subprocess_good": subprocess_result,
|
|
"run_module_good": run_module_result,
|
|
"sqlite_roundtrip": sqlite_result,
|
|
"identity_roundtrip": identity_file_result,
|
|
"loopback_tcp": loopback_result,
|
|
"unicode_path_good": unicode_result,
|
|
"rnode_support_good": rnode_result,
|
|
"bot_launcher_good": bot_launcher_result,
|
|
"http_status_good": web_results.get(
|
|
"http_status_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_app_info_good": web_results.get(
|
|
"http_app_info_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_config_good": web_results.get(
|
|
"http_config_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_db_health_good": web_results.get(
|
|
"http_db_health_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_auth_csrf_good": web_results.get(
|
|
"http_auth_csrf_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_bots_status_good": web_results.get(
|
|
"http_bots_status_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_security_good": web_results.get(
|
|
"http_security_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_interfaces_good": web_results.get(
|
|
"http_interfaces_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_reticulum_instance_good": web_results.get(
|
|
"http_reticulum_instance_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_identities_good": web_results.get(
|
|
"http_identities_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_favourites_good": web_results.get(
|
|
"http_favourites_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_telephone_good": web_results.get(
|
|
"http_telephone_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_plugins_good": web_results.get(
|
|
"http_plugins_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_plugins_trust_good": web_results.get(
|
|
"http_plugins_trust_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_sideband_plugins_good": web_results.get(
|
|
"http_sideband_plugins_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_sideband_config_good": web_results.get(
|
|
"http_sideband_config_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_rrc_hubs_good": web_results.get(
|
|
"http_rrc_hubs_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"http_rrc_servers_good": web_results.get(
|
|
"http_rrc_servers_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"plugins_runtime_good": plugins_runtime_result,
|
|
"websocket_good": web_results.get(
|
|
"websocket_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"websocket_rns_link_good": web_results.get(
|
|
"websocket_rns_link_good",
|
|
{"status": "failed", "reason": "missing"},
|
|
),
|
|
"bots_lifecycle": {
|
|
"status": "ok" if bots_ok else "failed",
|
|
"reason": bots_reason,
|
|
},
|
|
}
|
|
|
|
@property
|
|
def database_path(self):
|
|
return self.current_context.database_path if self.current_context else None
|
|
|
|
@property
|
|
def _identity_session_id(self):
|
|
return self.current_context.session_id if self.current_context else 0
|
|
|
|
@_identity_session_id.setter
|
|
def _identity_session_id(self, value):
|
|
if self.current_context:
|
|
self.current_context.session_id = value
|
|
|
|
def get_public_path(self, filename=""):
|
|
if self.public_dir_override:
|
|
return os.path.join(self.public_dir_override, filename)
|
|
return get_file_path(os.path.join("public", filename))
|
|
|
|
@staticmethod
|
|
def _normalize_reticulum_config_dir(config_candidate: str | None) -> str:
|
|
"""Normalize Reticulum config candidate to a config directory path."""
|
|
candidate = config_candidate
|
|
if not candidate:
|
|
candidate = (
|
|
getattr(RNS.Reticulum, "configdir", None)
|
|
or os.path.dirname(getattr(RNS.Reticulum, "configpath", "") or "")
|
|
or os.path.expanduser("~/.reticulum")
|
|
)
|
|
|
|
candidate = os.path.expanduser(str(candidate))
|
|
# Reticulum's config file is plaintext named "config" (no extension).
|
|
# If a file path is provided, convert it to its parent directory.
|
|
if os.path.basename(candidate) == "config" and not os.path.isdir(candidate):
|
|
return os.path.dirname(candidate) or os.path.expanduser("~/.reticulum")
|
|
return candidate
|
|
|
|
def _reticulum_config_file_path(self) -> str:
|
|
return os.path.join(
|
|
self._normalize_reticulum_config_dir(self.reticulum_config_dir),
|
|
"config",
|
|
)
|
|
|
|
@staticmethod
|
|
def _write_rns_reticulum_default_config_file(config_path: str) -> str:
|
|
"""Write RNS stock default config to config_path; return on-disk text.
|
|
|
|
Uses the same template and ConfigObj path as Reticulum.__create_default_config.
|
|
"""
|
|
from RNS.vendor.configobj import ConfigObj
|
|
|
|
rns_reticulum_mod = importlib.import_module("RNS.Reticulum")
|
|
default_spec = rns_reticulum_mod.__default_rns_config__
|
|
config_dir = os.path.dirname(config_path) or os.path.abspath(".")
|
|
if not os.path.isdir(config_dir):
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
cfg = ConfigObj(default_spec)
|
|
cfg.filename = config_path
|
|
cfg.write()
|
|
with open(config_path) as f:
|
|
return f.read()
|
|
|
|
def backup_database(self, backup_path=None):
|
|
if not self.database:
|
|
raise RuntimeError("Database not initialized")
|
|
return self.database.backup_database(self.storage_path, backup_path)
|
|
|
|
def list_database_backups(self):
|
|
if not self.database:
|
|
raise RuntimeError("Database not initialized")
|
|
return self.database.list_auto_backups(self.storage_path)
|
|
|
|
def export_database_backup(self, name: str, dest_path: str):
|
|
if not self.database:
|
|
raise RuntimeError("Database not initialized")
|
|
return self.database.copy_auto_backup(self.storage_path, name, dest_path)
|
|
|
|
def prepare_for_database_restore(self) -> str | None:
|
|
db_path = self.database_path
|
|
self._teardown_all_contexts_for_reload()
|
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
|
|
|
if DatabaseProvider._instance is not None:
|
|
DatabaseProvider._instance.close_all()
|
|
DatabaseProvider._instance = None
|
|
return db_path
|
|
|
|
@staticmethod
|
|
def _schedule_process_restart(delay: float = 1.0) -> None:
|
|
def restart():
|
|
time.sleep(delay)
|
|
try:
|
|
os.execv(sys.executable, [sys.executable] + sys.argv) # noqa: S606
|
|
except Exception as e:
|
|
print(f"Failed to restart: {e}")
|
|
os._exit(0)
|
|
|
|
threading.Thread(target=restart, daemon=True).start()
|
|
|
|
def restore_database(self, backup_path, *, relaunch: bool = False):
|
|
db_path = self.prepare_for_database_restore()
|
|
if not db_path:
|
|
raise RuntimeError("Database path is unknown")
|
|
from meshchatx.src.backend.database import Database
|
|
|
|
db = Database(db_path)
|
|
try:
|
|
result = db.restore_database(backup_path)
|
|
finally:
|
|
db.close_all()
|
|
identity_storage_file = os.path.join(os.path.dirname(db_path), "identity")
|
|
main_identity_file = self.identity_file_path or os.path.join(
|
|
self.storage_dir,
|
|
"identity",
|
|
)
|
|
if os.path.isfile(identity_storage_file):
|
|
os.makedirs(os.path.dirname(main_identity_file), exist_ok=True)
|
|
shutil.copy2(identity_storage_file, main_identity_file)
|
|
if relaunch:
|
|
self._schedule_process_restart()
|
|
return result
|
|
|
|
def auto_recover_database(self, *, relaunch: bool = True) -> dict:
|
|
from meshchatx.src.backend.database.auto_recover import (
|
|
run_auto_database_recover,
|
|
)
|
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
|
|
|
storage = self.storage_path
|
|
if not storage:
|
|
msg = "Storage path is unknown"
|
|
raise RuntimeError(msg)
|
|
|
|
def restore_fn(path: str) -> dict:
|
|
return self.restore_database(path, relaunch=relaunch)
|
|
|
|
def sqlite_recover_fn() -> dict:
|
|
path = self.database_path
|
|
if not path:
|
|
msg = "Database path is unknown"
|
|
raise RuntimeError(msg)
|
|
from meshchatx.src.backend.database import Database
|
|
|
|
db = Database(path)
|
|
return db.run_database_recovery()
|
|
|
|
return run_auto_database_recover(
|
|
storage,
|
|
self.database_path,
|
|
DatabaseSchema.LATEST_VERSION,
|
|
restore_fn,
|
|
sqlite_recover_fn=sqlite_recover_fn,
|
|
)
|
|
|
|
def _resolve_database_restore_path(self, path: str) -> str | None:
|
|
"""Resolve a restore zip under identity snapshots or database-backups only."""
|
|
if not isinstance(path, str) or not path or "\x00" in path:
|
|
return None
|
|
storage = self.storage_path
|
|
if not storage:
|
|
return None
|
|
allowed_roots = [
|
|
os.path.join(storage, "snapshots"),
|
|
os.path.join(storage, "database-backups"),
|
|
]
|
|
candidates: list[str] = []
|
|
if os.path.isabs(path):
|
|
candidates.append(path)
|
|
else:
|
|
for root in allowed_roots:
|
|
candidates.append(os.path.join(root, path))
|
|
if not path.endswith(".zip"):
|
|
candidates.append(os.path.join(root, path + ".zip"))
|
|
for candidate in candidates:
|
|
real = os.path.realpath(candidate)
|
|
if not os.path.isfile(real):
|
|
continue
|
|
if any(is_path_within_dir(real, root) for root in allowed_roots):
|
|
return real
|
|
return None
|
|
|
|
def reset_password(self):
|
|
"""Clear the stored password hash so a new password can be set via the web UI."""
|
|
if self.config.auth_password_hash.get() is not None:
|
|
self.config.auth_password_hash.set(None)
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _disable_rnode_interfaces_on_android(config_path: str) -> bool:
|
|
"""Disable enabled RNode* interfaces in Reticulum config (Android recovery helper)."""
|
|
if not _is_chaquopy_android():
|
|
return False
|
|
from meshchatx.src.backend.rnode_support import (
|
|
disable_rnode_interfaces_in_config,
|
|
)
|
|
|
|
return disable_rnode_interfaces_in_config(config_path, is_android=True)
|
|
|
|
def _ensure_reticulum_config(self, materialize: bool = True):
|
|
"""Normalize reticulum_config_dir and optionally ensure a config file exists.
|
|
|
|
When materialize is true (default), write RNS stock defaults if the file
|
|
is missing or lacks required sections so first Reticulum startup is reliable.
|
|
|
|
API handlers that must distinguish a missing file (e.g. raw config GET) pass
|
|
materialize=False to only normalize the directory path.
|
|
"""
|
|
config_dir = self._normalize_reticulum_config_dir(self.reticulum_config_dir)
|
|
self.reticulum_config_dir = config_dir
|
|
if not materialize:
|
|
return
|
|
if not getattr(self, "_reticulum_instance_name_startup_repair_done", False):
|
|
self._repair_reticulum_instance_name_corruption()
|
|
self._reticulum_instance_name_startup_repair_done = True
|
|
config_path = os.path.join(config_dir, "config")
|
|
needs_default = not reticulum_config_has_required_sections(config_path)
|
|
if not needs_default:
|
|
repair_unparseable_reticulum_config(
|
|
config_path,
|
|
write_default=self._write_rns_reticulum_default_config_file,
|
|
)
|
|
needs_default = not reticulum_config_has_required_sections(config_path)
|
|
if needs_default:
|
|
if not os.path.isdir(config_dir):
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
self._write_rns_reticulum_default_config_file(config_path)
|
|
try:
|
|
from RNS.vendor.configobj import ConfigObj
|
|
|
|
cfg = ConfigObj(config_path)
|
|
if "default_bootstrap_only" in cfg.get("reticulum", {}):
|
|
cfg["reticulum"].pop("default_bootstrap_only", None)
|
|
cfg.write()
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to scrub default_bootstrap_only from %s: %s",
|
|
config_path,
|
|
exc,
|
|
)
|
|
from meshchatx.src.backend.rnode_support import (
|
|
guard_invalid_rnode_txpower_in_config,
|
|
guard_rnode_interfaces_on_android,
|
|
guard_rnode_interfaces_on_desktop,
|
|
normalize_rnode_tcp_host_in_config,
|
|
)
|
|
|
|
normalize_rnode_tcp_host_in_config(config_path)
|
|
guard_rnode_interfaces_on_android(config_path)
|
|
guard_rnode_interfaces_on_desktop(config_path)
|
|
guard_invalid_rnode_txpower_in_config(config_path)
|
|
i2p_support.guard_i2p_interfaces_in_config(config_path)
|
|
ensure_safe_reticulum_runtime_flags(config_path)
|
|
try:
|
|
from meshchatx.src.backend.interface_module_store import (
|
|
ensure_bundled_interface_modules,
|
|
)
|
|
|
|
ensure_bundled_interface_modules(config_dir)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to sync bundled interface modules into %s: %s",
|
|
config_dir,
|
|
exc,
|
|
)
|
|
|
|
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
|
|
previous = getattr(self, "_startup_stage", None)
|
|
self._startup_stage = stage
|
|
if error is not None:
|
|
self._startup_error = error
|
|
# Same stage can be set from both the network-setup wrapper and
|
|
# setup_identity. Only log transitions to keep console noise down.
|
|
if previous != stage or error is not None:
|
|
print(f"Startup stage: {stage}", flush=True)
|
|
|
|
def _mark_network_ready(self) -> None:
|
|
self._network_ready = True
|
|
self._network_degraded = False
|
|
self._ui_ready = True
|
|
self._startup_stage = "ready"
|
|
self._startup_error = None
|
|
self._rns_recovery_actions = []
|
|
self._network_ready_event.set()
|
|
self._schedule_reticulum_signal_handlers()
|
|
|
|
def _mark_network_degraded(self, error: str) -> None:
|
|
"""Keep HTTP/UI alive when the mesh stack cannot start."""
|
|
self._network_ready = False
|
|
self._network_degraded = True
|
|
self._ui_ready = True
|
|
self._startup_stage = "failed"
|
|
self._startup_error = error
|
|
print(f"Network degraded: {error}", flush=True)
|
|
|
|
def _schedule_reticulum_signal_handlers(self) -> None:
|
|
"""Install RNS signal handlers on the main asyncio loop when possible."""
|
|
if threading.current_thread() is threading.main_thread():
|
|
_install_reticulum_signal_handlers()
|
|
return
|
|
loop = AsyncUtils.main_loop
|
|
if loop is None or not loop.is_running():
|
|
return
|
|
try:
|
|
loop.call_soon_threadsafe(_install_reticulum_signal_handlers)
|
|
except Exception:
|
|
pass
|
|
|
|
def _ensure_sideband_telemetry_loop(self) -> None:
|
|
config = self.sideband_plugin_loader.get_config()
|
|
should_run = bool(config.get("service_plugins_enabled"))
|
|
if should_run and not self._sideband_telemetry_running:
|
|
self._sideband_telemetry_running = True
|
|
|
|
def telemetry_job():
|
|
while self._sideband_telemetry_running:
|
|
try:
|
|
self.sideband_plugin_loader.update_telemetry()
|
|
except Exception:
|
|
pass
|
|
time.sleep(60)
|
|
|
|
self._sideband_telemetry_thread = threading.Thread(
|
|
target=telemetry_job,
|
|
daemon=True,
|
|
name="sideband-plugin-telemetry",
|
|
)
|
|
self._sideband_telemetry_thread.start()
|
|
elif not should_run:
|
|
self._sideband_telemetry_running = False
|
|
|
|
def _startup_status_payload(self) -> dict:
|
|
demo_fields = {
|
|
"demo_mode": self.demo_mode,
|
|
"altcha_enabled": self.altcha_enabled,
|
|
"auth_page_hint": self.auth_page_hint,
|
|
}
|
|
if self._startup_stage == "failed" or self._startup_error:
|
|
payload = {
|
|
"status": "failed",
|
|
"stage": "failed",
|
|
"network_ready": False,
|
|
"network_degraded": True,
|
|
"ui_ready": True,
|
|
"listen_host": self.listen_host,
|
|
"listen_port": self.listen_port,
|
|
"https_enabled": self.use_https,
|
|
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
|
|
"plugins_enabled": self.plugins_enabled,
|
|
**demo_fields,
|
|
**self._landlock_status_dict(),
|
|
}
|
|
if self._startup_error:
|
|
payload["error"] = self._startup_error
|
|
if self._rns_recovery_actions:
|
|
payload["recovery_actions"] = list(self._rns_recovery_actions)
|
|
return payload
|
|
ready = bool(self._network_ready) and bool(
|
|
self.current_context and self.current_context.running,
|
|
)
|
|
stage = "ready" if ready else (self._startup_stage or "starting")
|
|
return {
|
|
"status": "ok" if ready else "starting",
|
|
"stage": stage,
|
|
"network_ready": ready,
|
|
"network_degraded": False,
|
|
"ui_ready": True if ready else bool(self._ui_ready),
|
|
"listen_host": self.listen_host,
|
|
"listen_port": self.listen_port,
|
|
"https_enabled": self.use_https,
|
|
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
|
|
"plugins_enabled": self.plugins_enabled,
|
|
**demo_fields,
|
|
**self._landlock_status_dict(),
|
|
}
|
|
|
|
def wait_until_network_ready(self, timeout: float | None = None) -> bool:
|
|
if (
|
|
self._network_ready
|
|
and self.current_context
|
|
and self.current_context.running
|
|
):
|
|
return True
|
|
return self._network_ready_event.wait(timeout)
|
|
|
|
def start_network_setup_in_background(
|
|
self,
|
|
identity: RNS.Identity | None = None,
|
|
) -> None:
|
|
pending = identity if identity is not None else self._pending_identity
|
|
if pending is None:
|
|
raise RuntimeError("No identity available for network setup")
|
|
self._pending_identity = pending
|
|
if (
|
|
self._network_ready
|
|
and self.current_context
|
|
and self.current_context.running
|
|
):
|
|
return
|
|
with self._network_setup_lock:
|
|
if self._network_setup_thread and self._network_setup_thread.is_alive():
|
|
return
|
|
self._set_startup_stage("starting")
|
|
thread = threading.Thread(
|
|
target=self._run_network_setup,
|
|
name="meshchatx-network-setup",
|
|
daemon=True,
|
|
)
|
|
self._network_setup_thread = thread
|
|
thread.start()
|
|
|
|
def _run_network_setup(self) -> None:
|
|
identity = self._pending_identity
|
|
if identity is None:
|
|
self._set_startup_stage("failed", "No identity available for network setup")
|
|
return
|
|
try:
|
|
self.setup_identity(identity)
|
|
if self.config is not None and getattr(self, "session_secret_key", None):
|
|
try:
|
|
self.config.auth_session_secret.set(self.session_secret_key)
|
|
except Exception as exc:
|
|
print(f"Failed to persist session secret into config: {exc}")
|
|
self._mark_network_ready()
|
|
self._finish_deferred_startup_services()
|
|
print("Network stack ready", flush=True)
|
|
if self.websocket_clients:
|
|
try:
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
{
|
|
"type": "startup_status",
|
|
"status": "ok",
|
|
"stage": "ready",
|
|
"network_ready": True,
|
|
},
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception as exc:
|
|
traceback.print_exc()
|
|
self._mark_network_degraded(str(exc))
|
|
if self.websocket_clients:
|
|
try:
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
{
|
|
"type": "startup_status",
|
|
"status": "failed",
|
|
"stage": "failed",
|
|
"network_ready": False,
|
|
"network_degraded": True,
|
|
"ui_ready": True,
|
|
"error": str(exc),
|
|
},
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def setup_identity(self, identity: RNS.Identity):
|
|
identity_hash = identity.hash.hex()
|
|
|
|
self.running = True
|
|
|
|
# Check if we already have a context for this identity
|
|
if identity_hash in self.contexts:
|
|
identity_storage_dir = os.path.join(
|
|
self.storage_dir,
|
|
"identities",
|
|
identity_hash,
|
|
)
|
|
if not os.path.isdir(identity_storage_dir):
|
|
stale = self.contexts.pop(identity_hash)
|
|
with contextlib.suppress(Exception):
|
|
stale.teardown()
|
|
else:
|
|
self.current_context = self.contexts[identity_hash]
|
|
if not self.current_context.running:
|
|
self.current_context.setup()
|
|
self.web_audio_bridge = WebAudioBridge(
|
|
self.current_context.telephone_manager,
|
|
self.current_context.config,
|
|
force_enabled=self.web_audio_required(),
|
|
)
|
|
if self._network_ready:
|
|
self._finish_deferred_startup_services()
|
|
self._apply_demo_mode_runtime()
|
|
return
|
|
|
|
# Initialize Reticulum if not already done
|
|
if not hasattr(self, "reticulum"):
|
|
self._set_startup_stage("rns")
|
|
self._ensure_reticulum_config()
|
|
rns_loglevel = _resolve_rns_loglevel(self._rns_loglevel_cli)
|
|
self.reticulum = _create_reticulum_instance(
|
|
self.reticulum_config_dir,
|
|
loglevel=rns_loglevel,
|
|
)
|
|
_restore_rns_console_logging_after_reticulum_init(self)
|
|
self.page_node_manager.load_nodes()
|
|
self.plugin_manager.set_app(self)
|
|
|
|
# Create new context
|
|
self._set_startup_stage("identity")
|
|
context = IdentityContext(identity, self)
|
|
self.contexts[identity_hash] = context
|
|
self.current_context = context
|
|
context.setup()
|
|
self.web_audio_bridge = WebAudioBridge(
|
|
context.telephone_manager,
|
|
context.config,
|
|
force_enabled=self.web_audio_required(),
|
|
)
|
|
|
|
for node in self.page_node_manager.nodes.values():
|
|
if node.running and node.destination:
|
|
self._register_local_page_node_announce(node)
|
|
|
|
# Link database to memory log handler
|
|
memory_log_handler.set_database(context.database)
|
|
|
|
# Wire crash recovery with DB + log handler for adaptive diagnostics
|
|
if hasattr(self, "_crash_recovery") and self._crash_recovery:
|
|
self._crash_recovery.set_database(context.database)
|
|
self._crash_recovery.log_handler = memory_log_handler
|
|
|
|
# Start health monitor if not already running
|
|
if not hasattr(self, "_health_monitor") or self._health_monitor is None:
|
|
self._health_monitor = HealthMonitor(
|
|
log_handler=memory_log_handler,
|
|
app=self,
|
|
)
|
|
self._health_monitor.start()
|
|
|
|
if self._network_ready:
|
|
self._finish_deferred_startup_services()
|
|
|
|
self._apply_demo_mode_runtime()
|
|
|
|
def _apply_demo_mode_runtime(self) -> None:
|
|
if not self.demo_mode:
|
|
return
|
|
ctx = self.current_context
|
|
if not ctx or not ctx.config:
|
|
return
|
|
self.plugins_enabled = False
|
|
ctx.config.privacy_mode_enabled.set(True)
|
|
ctx.config.auto_announce_enabled.set(False)
|
|
if self.auth_enabled_initial:
|
|
ctx.config.auth_enabled.set(True)
|
|
if self.auth_enabled and ctx.config.auth_password_hash.get() is None:
|
|
password = demo_auth_password_from_env()
|
|
password_hash = bcrypt.hashpw(
|
|
password.encode("utf-8"),
|
|
bcrypt.gensalt(),
|
|
).decode("utf-8")
|
|
ctx.config.auth_password_hash.set(password_hash)
|
|
|
|
def _finish_deferred_startup_services(self) -> None:
|
|
"""Start non-critical services after network_ready is published."""
|
|
context = self.current_context
|
|
if context is not None:
|
|
try:
|
|
context.setup_deferred_services()
|
|
except Exception as exc:
|
|
print(f"Deferred identity services failed: {exc}", flush=True)
|
|
self._start_deferred_reticulum_services()
|
|
|
|
def _start_deferred_reticulum_services(self) -> None:
|
|
if self._reticulum_secondary_started:
|
|
return
|
|
if not hasattr(self, "reticulum"):
|
|
return
|
|
self._reticulum_secondary_started = True
|
|
try:
|
|
self.page_node_manager.start_all()
|
|
for node in self.page_node_manager.nodes.values():
|
|
if node.running and node.destination:
|
|
self._register_local_page_node_announce(node)
|
|
except Exception as exc:
|
|
print(f"Deferred page node start failed: {exc}", flush=True)
|
|
if self.plugins_enabled:
|
|
try:
|
|
self.plugin_manager.install_bundled_examples()
|
|
except Exception as exc:
|
|
print(f"Bundled plugin sync failed: {exc}", flush=True)
|
|
try:
|
|
self.sideband_plugin_loader.reload()
|
|
self._ensure_sideband_telemetry_loop()
|
|
except Exception as exc:
|
|
print(f"Sideband plugin loader init failed: {exc}")
|
|
|
|
def _checkpoint_and_close(self):
|
|
# delegated to database instance
|
|
self.database._checkpoint_and_close()
|
|
|
|
def _get_identity_bytes(self) -> bytes:
|
|
return self.identity_manager.get_identity_bytes(self.identity)
|
|
|
|
def cleanup_rns_state_for_identity(self, identity_hash):
|
|
if not identity_hash:
|
|
return
|
|
|
|
if isinstance(identity_hash, str):
|
|
identity_hash_bytes = bytes.fromhex(identity_hash)
|
|
identity_hash_hex = identity_hash
|
|
else:
|
|
identity_hash_bytes = identity_hash
|
|
identity_hash_hex = identity_hash.hex()
|
|
|
|
print(f"Aggressively cleaning up RNS state for identity {identity_hash_hex}")
|
|
|
|
# 1. Deregister destinations
|
|
try:
|
|
# We iterate over a copy of the list because we are modifying it
|
|
for destination in list(RNS.Transport.destinations):
|
|
match = False
|
|
# check identity hash
|
|
if hasattr(destination, "identity") and destination.identity:
|
|
if destination.identity.hash == identity_hash_bytes:
|
|
match = True
|
|
|
|
if match:
|
|
print(
|
|
f"Deregistering RNS destination {destination} ({RNS.prettyhexrep(destination.hash)})",
|
|
)
|
|
RNS.Transport.deregister_destination(destination)
|
|
except Exception as e:
|
|
print(f"Error while cleaning up RNS destinations: {e}")
|
|
|
|
# 2. Teardown active links
|
|
try:
|
|
for link in list(RNS.Transport.active_links):
|
|
match = False
|
|
# check if local identity or destination matches
|
|
if hasattr(link, "destination") and link.destination:
|
|
if (
|
|
hasattr(link.destination, "identity")
|
|
and link.destination.identity
|
|
):
|
|
if link.destination.identity.hash == identity_hash_bytes:
|
|
match = True
|
|
|
|
if match:
|
|
print(f"Tearing down RNS link {link}")
|
|
try:
|
|
link.teardown()
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Error while cleaning up RNS links: {e}")
|
|
|
|
def _clear_mesh_link_caches(self):
|
|
"""Drop process-global mesh links so identity switch cannot reuse sessions."""
|
|
clear_all_cached_links()
|
|
clear_all_nomadnet_cached_links()
|
|
|
|
def teardown_identity(self):
|
|
if self.current_context:
|
|
self.running = False
|
|
identity_hash = self.current_context.identity_hash
|
|
self.current_context.teardown()
|
|
if identity_hash in self.contexts:
|
|
del self.contexts[identity_hash]
|
|
self.current_context = None
|
|
# Drop Nomad and RNS links that may have identified as the prior identity.
|
|
self._clear_mesh_link_caches()
|
|
gc.collect()
|
|
|
|
def _teardown_all_contexts_for_reload(self):
|
|
# Stop per-identity long-running services before tearing down contexts.
|
|
for identity_hash in list(self.contexts.keys()):
|
|
ctx = self.contexts.get(identity_hash)
|
|
if ctx is None:
|
|
continue
|
|
bot_handler = getattr(ctx, "bot_handler", None)
|
|
if bot_handler is not None:
|
|
with contextlib.suppress(Exception):
|
|
bot_handler.stop_all()
|
|
with contextlib.suppress(Exception):
|
|
self.stop_local_propagation_node(context=ctx)
|
|
|
|
# Stop page node mesh servers before resetting transport state.
|
|
if hasattr(self, "page_node_manager"):
|
|
with contextlib.suppress(Exception):
|
|
self.page_node_manager.teardown()
|
|
|
|
for identity_hash in list(self.contexts.keys()):
|
|
ctx = self.contexts.get(identity_hash)
|
|
if ctx is None:
|
|
continue
|
|
with contextlib.suppress(Exception):
|
|
ctx.teardown()
|
|
|
|
self.contexts.clear()
|
|
self.current_context = None
|
|
self.running = False
|
|
# Same drop as teardown_identity. Reload and zip restore must not keep
|
|
# Nomad or RNS Link sessions from the torn-down identities.
|
|
self._clear_mesh_link_caches()
|
|
gc.collect()
|
|
|
|
async def _send_rns_reload_status(
|
|
self,
|
|
stage: str,
|
|
message: str,
|
|
*,
|
|
level: str = "info",
|
|
in_progress: bool = True,
|
|
):
|
|
with contextlib.suppress(Exception):
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "reticulum_reload_status",
|
|
"stage": stage,
|
|
"message": message,
|
|
"level": level,
|
|
"in_progress": in_progress,
|
|
},
|
|
),
|
|
)
|
|
|
|
def _force_close_listener(self, listener):
|
|
"""Aggressively close a multiprocessing.connection.Listener.
|
|
|
|
Calls Listener.close() and additionally drills through the inner
|
|
SocketListener wrapper to close the underlying socket file descriptor.
|
|
Necessary because the rpc_loop thread can retain references to the
|
|
listener that prevent the kernel from releasing abstract AF_UNIX
|
|
addresses on plain close().
|
|
"""
|
|
try:
|
|
if hasattr(listener, "close"):
|
|
with contextlib.suppress(Exception):
|
|
listener.close()
|
|
finally:
|
|
socket_type = getattr(socket, "SocketType", None)
|
|
wrappers = [listener]
|
|
|
|
listener_inner = getattr(listener, "_listener", None)
|
|
if listener_inner is not None:
|
|
wrappers.append(listener_inner)
|
|
|
|
for wrapper in list(wrappers):
|
|
inner_socket = getattr(wrapper, "_socket", None)
|
|
if inner_socket is not None:
|
|
wrappers.append(inner_socket)
|
|
plain_socket = getattr(wrapper, "socket", None)
|
|
if plain_socket is not None:
|
|
wrappers.append(plain_socket)
|
|
|
|
for obj in wrappers:
|
|
if socket_type is None or not isinstance(obj, socket_type):
|
|
continue
|
|
fileno = -1
|
|
try:
|
|
fileno = obj.fileno()
|
|
except Exception:
|
|
pass
|
|
with contextlib.suppress(Exception):
|
|
obj.close()
|
|
if fileno != -1:
|
|
try:
|
|
os.close(fileno)
|
|
except OSError:
|
|
pass
|
|
|
|
for wrapper in wrappers:
|
|
with contextlib.suppress(Exception):
|
|
if hasattr(wrapper, "_socket"):
|
|
wrapper._socket = None
|
|
with contextlib.suppress(Exception):
|
|
if hasattr(wrapper, "socket"):
|
|
wrapper.socket = None
|
|
with contextlib.suppress(Exception):
|
|
if hasattr(listener, "_listener"):
|
|
listener._listener = None
|
|
|
|
def _force_close_abstract_unix_addr(self, addr) -> bool:
|
|
"""Close every socket FD in the current process bound to addr.
|
|
|
|
Returns True if any FD was closed. addr is expected to be a string
|
|
starting with a NUL byte (abstract AF_UNIX namespace).
|
|
"""
|
|
if not (isinstance(addr, str) and addr.startswith("\0")):
|
|
return False
|
|
|
|
target_bytes = addr.encode("utf-8", errors="replace")
|
|
target_no_nul = target_bytes[1:]
|
|
closed_any = False
|
|
|
|
try:
|
|
current_process = psutil.Process()
|
|
for conn in current_process.net_connections(kind="unix"):
|
|
try:
|
|
laddr = getattr(conn, "laddr", None)
|
|
fd = getattr(conn, "fd", -1)
|
|
if not laddr or fd in (-1, None):
|
|
continue
|
|
|
|
if isinstance(laddr, str):
|
|
laddr_bytes = laddr.encode("utf-8", errors="replace")
|
|
elif isinstance(laddr, bytes):
|
|
laddr_bytes = laddr
|
|
else:
|
|
continue
|
|
|
|
laddr_no_nul = (
|
|
laddr_bytes[1:]
|
|
if laddr_bytes.startswith(b"\0")
|
|
else laddr_bytes
|
|
)
|
|
|
|
if (
|
|
laddr_bytes in (target_bytes, target_no_nul)
|
|
or laddr_no_nul == target_no_nul
|
|
):
|
|
try:
|
|
os.close(fd)
|
|
closed_any = True
|
|
print(
|
|
f"Force closed lingering abstract UNIX FD {fd} for {addr[1:]}",
|
|
)
|
|
except OSError as fd_err:
|
|
print(
|
|
f"Failed to close FD {fd} for {addr[1:]}: {fd_err}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Error scanning process for abstract UNIX FDs: {e}")
|
|
|
|
if closed_any:
|
|
gc.collect()
|
|
time.sleep(0.2)
|
|
|
|
return closed_any
|
|
|
|
_reload_instance_suffix_re = re.compile(r"-reload-(\d+)-(\d+)$")
|
|
_meshchat_reload_pid_max = 4_194_304
|
|
_meshchat_reload_epoch_min = 1_577_836_800
|
|
_meshchat_reload_epoch_max = 4_102_444_800
|
|
|
|
@staticmethod
|
|
def _reset_transport_globals_for_reload() -> None:
|
|
"""Clear RNS Transport globals so a new Reticulum can start cleanly.
|
|
|
|
Reticulum.exit_handler sets Transport._should_run = False. Upstream
|
|
Transport.start never flips it back, so hot reload must restore it or
|
|
the new jobloop exits immediately and path/link tools stay dead while
|
|
interface RX/TX counters still update.
|
|
"""
|
|
RNS.Transport._should_run = True
|
|
if hasattr(RNS.Transport, "jobs_running"):
|
|
RNS.Transport.jobs_running = False
|
|
RNS.Transport.interfaces = []
|
|
RNS.Transport.local_client_interfaces = []
|
|
RNS.Transport.destinations = []
|
|
if hasattr(RNS.Transport, "destinations_map"):
|
|
RNS.Transport.destinations_map = {}
|
|
RNS.Transport.active_links = []
|
|
RNS.Transport.pending_links = []
|
|
RNS.Transport.announce_handlers = []
|
|
RNS.Transport.announce_table = {}
|
|
RNS.Transport.path_table = {}
|
|
RNS.Transport.reverse_table = {}
|
|
RNS.Transport.link_table = {}
|
|
RNS.Transport.held_announces = {}
|
|
RNS.Transport.tunnels = {}
|
|
RNS.Transport.path_requests = {}
|
|
RNS.Transport.path_states = {}
|
|
RNS.Transport.announce_rate_table = {}
|
|
RNS.Transport.control_destinations = []
|
|
RNS.Transport.control_hashes = []
|
|
RNS.Transport.mgmt_destinations = []
|
|
RNS.Transport.mgmt_hashes = []
|
|
|
|
@staticmethod
|
|
def _looks_like_meshchat_hot_reload_tail(pid: int, epoch: int) -> bool:
|
|
"""Limit repairs to suffixes reload_reticulum actually writes.
|
|
|
|
Hot reload uses -reload-{os.getpid()}-{int(time.time())}. Names like
|
|
my-net-reload-peer must not be truncated.
|
|
"""
|
|
if pid < 1 or pid > ReticulumMeshChat._meshchat_reload_pid_max:
|
|
return False
|
|
if (
|
|
epoch < ReticulumMeshChat._meshchat_reload_epoch_min
|
|
or epoch > ReticulumMeshChat._meshchat_reload_epoch_max
|
|
):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _strip_reload_instance_suffix(name):
|
|
"""Remove stacked MeshChat hot-reload tails only (validated pid + unix time)."""
|
|
if not isinstance(name, str):
|
|
return None
|
|
out = name.strip()
|
|
if not out:
|
|
return None
|
|
while True:
|
|
m = ReticulumMeshChat._reload_instance_suffix_re.search(out)
|
|
if not m or m.end() != len(out):
|
|
break
|
|
try:
|
|
pid = int(m.group(1))
|
|
epoch = int(m.group(2))
|
|
except ValueError:
|
|
break
|
|
if not ReticulumMeshChat._looks_like_meshchat_hot_reload_tail(pid, epoch):
|
|
break
|
|
out = out[: m.start()].strip()
|
|
return out or None
|
|
|
|
def _read_reticulum_instance_name(self):
|
|
"""Return current Reticulum instance_name from config or None."""
|
|
config_dir = self._normalize_reticulum_config_dir(
|
|
getattr(self, "reticulum_config_dir", None),
|
|
)
|
|
config_path = os.path.join(config_dir, "config")
|
|
if not os.path.isfile(config_path):
|
|
return None
|
|
|
|
cp = configparser.ConfigParser()
|
|
try:
|
|
cp.read(config_path)
|
|
except configparser.Error:
|
|
return None
|
|
if not cp.has_section("reticulum"):
|
|
return None
|
|
return cp.get("reticulum", "instance_name", fallback=None)
|
|
|
|
def _repair_reticulum_instance_name_corruption(self):
|
|
"""Rewrite persisted instance_name if hot-reload suffixes were left on disk."""
|
|
raw = self._read_reticulum_instance_name()
|
|
if not raw:
|
|
return
|
|
cleaned = ReticulumMeshChat._strip_reload_instance_suffix(raw)
|
|
if cleaned == raw or cleaned is None:
|
|
return
|
|
self._write_reticulum_instance_name(cleaned)
|
|
|
|
def _write_reticulum_instance_name(self, instance_name):
|
|
"""Persist a Reticulum instance_name value into the config."""
|
|
config_dir = self._normalize_reticulum_config_dir(
|
|
getattr(self, "reticulum_config_dir", None),
|
|
)
|
|
config_path = os.path.join(config_dir, "config")
|
|
cp = configparser.ConfigParser()
|
|
try:
|
|
cp.read(config_path)
|
|
except configparser.Error:
|
|
cp = configparser.ConfigParser()
|
|
if not cp.has_section("reticulum"):
|
|
cp.add_section("reticulum")
|
|
cp.set("reticulum", "instance_name", instance_name)
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
cp.write(f)
|
|
|
|
async def reload_reticulum(self):
|
|
print("Hot reloading Reticulum stack...")
|
|
# Keep reference to old reticulum instance for cleanup
|
|
old_reticulum = getattr(self, "reticulum", None)
|
|
identity_to_restore = self.identity
|
|
identity_hashes = []
|
|
for ctx in list(self.contexts.values()):
|
|
with contextlib.suppress(Exception):
|
|
identity_hashes.append(ctx.identity.hash)
|
|
if not identity_hashes:
|
|
identity_hash = getattr(identity_to_restore, "hash", None)
|
|
if identity_hash:
|
|
identity_hashes.append(identity_hash)
|
|
|
|
try:
|
|
if identity_to_restore is None:
|
|
raise RuntimeError(
|
|
"Cannot reload Reticulum without an active identity context.",
|
|
)
|
|
await self._send_rns_reload_status(
|
|
"starting",
|
|
"Reloading RNS stack...",
|
|
)
|
|
|
|
# Signal background loops to exit
|
|
self._identity_session_id += 1
|
|
self._network_ready = False
|
|
|
|
await self._send_rns_reload_status(
|
|
"stopping-services",
|
|
"Stopping bots and mesh services across identities...",
|
|
)
|
|
self._teardown_all_contexts_for_reload()
|
|
|
|
# Give loops a moment to finish
|
|
await asyncio.sleep(2)
|
|
|
|
await self._send_rns_reload_status(
|
|
"deregistering",
|
|
"Deregistering destinations and active links...",
|
|
)
|
|
for identity_hash in identity_hashes:
|
|
self.cleanup_rns_state_for_identity(identity_hash)
|
|
|
|
# Close RNS instance first to let it detach interfaces naturally
|
|
await self._send_rns_reload_status(
|
|
"detaching",
|
|
"Detaching interfaces and shutting down Reticulum...",
|
|
)
|
|
try:
|
|
# Use class method to ensure all instances are cleaned up if any
|
|
RNS.Reticulum.exit_handler()
|
|
except Exception as e:
|
|
print(f"Warning during RNS exit: {e}")
|
|
|
|
# Aggressively close RNS interfaces to release sockets if they didn't close
|
|
try:
|
|
interfaces = []
|
|
if hasattr(RNS.Transport, "interfaces"):
|
|
interfaces.extend(RNS.Transport.interfaces)
|
|
if hasattr(RNS.Transport, "local_client_interfaces"):
|
|
interfaces.extend(RNS.Transport.local_client_interfaces)
|
|
|
|
for interface in interfaces:
|
|
try:
|
|
# Generic socketserver shutdown
|
|
if hasattr(interface, "server") and interface.server:
|
|
try:
|
|
interface.server.shutdown()
|
|
interface.server.server_close()
|
|
except Exception:
|
|
pass
|
|
|
|
# AutoInterface specific
|
|
if hasattr(interface, "interface_servers"):
|
|
for server in interface.interface_servers.values():
|
|
try:
|
|
server.shutdown()
|
|
server.server_close()
|
|
except Exception:
|
|
pass
|
|
|
|
# For LocalServerInterface which Reticulum doesn't close properly
|
|
if hasattr(interface, "server") and interface.server:
|
|
try:
|
|
interface.server.shutdown()
|
|
interface.server.server_close()
|
|
except Exception:
|
|
pass
|
|
|
|
# TCPClientInterface/etc
|
|
if hasattr(interface, "socket") and interface.socket:
|
|
try:
|
|
# Check if socket is still valid before shutdown
|
|
if (
|
|
hasattr(interface.socket, "fileno")
|
|
and interface.socket.fileno() != -1
|
|
):
|
|
try:
|
|
interface.socket.shutdown(socket.SHUT_RDWR)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
interface.socket.close()
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
interface.detach()
|
|
interface.detached = True
|
|
except Exception as e:
|
|
print(f"Warning closing interface during reload: {e}")
|
|
except Exception as e:
|
|
print(f"Warning during aggressive interface cleanup: {e}")
|
|
|
|
if old_reticulum:
|
|
rpc_listener_names = [
|
|
"rpc_listener",
|
|
"_Reticulum__rpc_listener",
|
|
"_rpc_listener",
|
|
]
|
|
for attr_name in rpc_listener_names:
|
|
if hasattr(old_reticulum, attr_name):
|
|
listener = getattr(old_reticulum, attr_name)
|
|
if listener:
|
|
try:
|
|
print(
|
|
f"Forcing closure of RPC listener in {attr_name}...",
|
|
)
|
|
self._force_close_listener(listener)
|
|
setattr(old_reticulum, attr_name, None)
|
|
except Exception as e:
|
|
print(f"Warning closing RPC listener {attr_name}: {e}")
|
|
|
|
# Clear RNS singleton and internal state to allow re-initialization
|
|
try:
|
|
# Reticulum uses private variables for singleton and state control
|
|
# We need to clear them so we can create a new instance
|
|
if hasattr(RNS.Reticulum, "_Reticulum__instance"):
|
|
# Keep the instance object until we're done waiting for sockets.
|
|
# Some Reticulum background workers still consult get_instance().
|
|
pass
|
|
if hasattr(RNS.Reticulum, "_Reticulum__exit_handler_ran"):
|
|
RNS.Reticulum._Reticulum__exit_handler_ran = False
|
|
if hasattr(RNS.Reticulum, "_Reticulum__interface_detach_ran"):
|
|
RNS.Reticulum._Reticulum__interface_detach_ran = False
|
|
|
|
self._reset_transport_globals_for_reload()
|
|
clear_all_cached_links()
|
|
clear_all_nomadnet_cached_links()
|
|
|
|
# Clear Identity globals
|
|
RNS.Identity.known_destinations = {}
|
|
RNS.Identity.known_ratchets = {}
|
|
|
|
# Unregister old exit handlers from atexit if possible
|
|
try:
|
|
# Reticulum uses a staticmethod exit_handler
|
|
atexit.unregister(RNS.Reticulum.exit_handler)
|
|
except Exception:
|
|
pass
|
|
|
|
except Exception as e:
|
|
print(f"Warning clearing RNS state: {e}")
|
|
|
|
# Remove reticulum instance from self
|
|
if hasattr(self, "reticulum"):
|
|
del self.reticulum
|
|
|
|
print("Waiting for ports to settle...")
|
|
await asyncio.sleep(4)
|
|
|
|
# Detect RPC type from reticulum instance if possible, otherwise default to both
|
|
rpc_addrs = []
|
|
if old_reticulum:
|
|
if hasattr(old_reticulum, "rpc_addr") and old_reticulum.rpc_addr:
|
|
rpc_addrs.append(
|
|
(
|
|
old_reticulum.rpc_addr,
|
|
getattr(old_reticulum, "rpc_type", "AF_INET"),
|
|
),
|
|
)
|
|
|
|
# Also check the config file for ports
|
|
try:
|
|
config_dir = self._normalize_reticulum_config_dir(
|
|
getattr(self, "reticulum_config_dir", None),
|
|
)
|
|
config_path = os.path.join(config_dir, "config")
|
|
if os.path.isfile(config_path):
|
|
cp = configparser.ConfigParser()
|
|
try:
|
|
cp.read(config_path)
|
|
except configparser.Error:
|
|
pass
|
|
else:
|
|
if cp.has_section("reticulum"):
|
|
rpc_port = cp.getint(
|
|
"reticulum",
|
|
"rpc_port",
|
|
fallback=37429,
|
|
)
|
|
rpc_bind = cp.get(
|
|
"reticulum",
|
|
"rpc_bind",
|
|
fallback="127.0.0.1",
|
|
)
|
|
shared_port = cp.getint(
|
|
"reticulum",
|
|
"shared_instance_port",
|
|
fallback=37428,
|
|
)
|
|
shared_bind = cp.get(
|
|
"reticulum",
|
|
"shared_instance_bind",
|
|
fallback="127.0.0.1",
|
|
)
|
|
|
|
# Only add if not already there
|
|
if not any(
|
|
addr == (rpc_bind, rpc_port) for addr, _ in rpc_addrs
|
|
):
|
|
rpc_addrs.append(((rpc_bind, rpc_port), "AF_INET"))
|
|
if not any(
|
|
addr == (shared_bind, shared_port)
|
|
for addr, _ in rpc_addrs
|
|
):
|
|
rpc_addrs.append(
|
|
((shared_bind, shared_port), "AF_INET"),
|
|
)
|
|
except Exception as e:
|
|
print(f"Warning reading Reticulum config for ports: {e}")
|
|
|
|
if not rpc_addrs:
|
|
rpc_addrs.append((("127.0.0.1", 37429), "AF_INET"))
|
|
rpc_addrs.append((("127.0.0.1", 37428), "AF_INET"))
|
|
|
|
abstract_unix_addr_in_use_after_wait = False
|
|
reload_probe_attempts = 3
|
|
for i in range(reload_probe_attempts):
|
|
all_free = True
|
|
for addr, family_str in rpc_addrs:
|
|
try:
|
|
family = (
|
|
socket.AF_INET
|
|
if family_str == "AF_INET"
|
|
else socket.AF_UNIX
|
|
)
|
|
s = socket.socket(family, socket.SOCK_STREAM)
|
|
s.settimeout(0.5)
|
|
try:
|
|
# Use SO_REUSEADDR to check if we can actually bind
|
|
if family == socket.AF_INET:
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind(addr)
|
|
s.close()
|
|
except OSError:
|
|
addr_display = addr
|
|
if (
|
|
family == socket.AF_UNIX
|
|
and isinstance(addr, str)
|
|
and addr.startswith("\0")
|
|
):
|
|
addr_display = addr[1:] + " (abstract)"
|
|
|
|
print(
|
|
f"RPC addr {addr_display} still in use... (attempt {i + 1}/{reload_probe_attempts})",
|
|
)
|
|
s.close()
|
|
is_abstract_unix_addr = (
|
|
family == socket.AF_UNIX
|
|
and isinstance(addr, str)
|
|
and addr.startswith("\0")
|
|
)
|
|
if is_abstract_unix_addr:
|
|
released = False
|
|
if self._force_close_abstract_unix_addr(addr):
|
|
try:
|
|
s2 = socket.socket(
|
|
socket.AF_UNIX,
|
|
socket.SOCK_STREAM,
|
|
)
|
|
try:
|
|
s2.bind(addr)
|
|
s2.close()
|
|
print(
|
|
f"Released abstract RPC addr {addr_display} from this process.",
|
|
)
|
|
released = True
|
|
except OSError:
|
|
s2.close()
|
|
except Exception:
|
|
pass
|
|
|
|
if released:
|
|
continue
|
|
|
|
all_free = False
|
|
continue
|
|
|
|
all_free = False
|
|
|
|
# If we are stuck, try to force close the connection manually
|
|
if i > 1:
|
|
try:
|
|
current_process = psutil.Process()
|
|
# We use kind='all' to catch both TCP and UNIX sockets
|
|
for conn in current_process.net_connections(
|
|
kind="all",
|
|
):
|
|
try:
|
|
match = False
|
|
if conn.laddr:
|
|
if (
|
|
family_str == "AF_INET"
|
|
and isinstance(conn.laddr, tuple)
|
|
):
|
|
# Match IP and port for IPv4
|
|
if conn.laddr.port == addr[1] and (
|
|
conn.laddr.ip == addr[0]
|
|
or addr[0] == "0.0.0.0" # noqa: S104
|
|
):
|
|
match = True
|
|
elif family_str == "AF_UNIX":
|
|
# Match path for UNIX sockets, including abstract
|
|
# Psutil sometimes returns abstract addresses as strings or bytes,
|
|
# with or without the leading null byte.
|
|
laddr = conn.laddr
|
|
|
|
# Normalize both to bytes for comparison
|
|
target_addr = (
|
|
addr
|
|
if isinstance(addr, bytes)
|
|
else addr.encode()
|
|
if isinstance(addr, str)
|
|
else b""
|
|
)
|
|
current_laddr = (
|
|
laddr
|
|
if isinstance(laddr, bytes)
|
|
else laddr.encode()
|
|
if isinstance(laddr, str)
|
|
else b""
|
|
)
|
|
|
|
if (
|
|
current_laddr == target_addr
|
|
or (
|
|
target_addr.startswith(
|
|
b"\0",
|
|
)
|
|
and current_laddr
|
|
== target_addr[1:]
|
|
)
|
|
or (
|
|
current_laddr.startswith(
|
|
b"\0",
|
|
)
|
|
and target_addr
|
|
== current_laddr[1:]
|
|
)
|
|
):
|
|
match = True
|
|
elif (
|
|
target_addr in current_laddr
|
|
or current_laddr in target_addr
|
|
):
|
|
# Last resort: partial match
|
|
if (
|
|
len(target_addr) > 5
|
|
and len(current_laddr) > 5
|
|
):
|
|
match = True
|
|
|
|
if match:
|
|
# If we found a match, force close the file descriptor
|
|
# to tell the OS to release the socket immediately.
|
|
status_str = getattr(
|
|
conn,
|
|
"status",
|
|
"UNKNOWN",
|
|
)
|
|
print(
|
|
f"Force closing lingering {family_str} connection {conn.laddr} (status: {status_str})",
|
|
)
|
|
|
|
try:
|
|
if (
|
|
hasattr(conn, "fd")
|
|
and conn.fd != -1
|
|
):
|
|
try:
|
|
os.close(conn.fd)
|
|
except Exception as fd_err:
|
|
print(
|
|
f"Failed to close FD {getattr(conn, 'fd', 'N/A')}: {fd_err}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print(
|
|
f"Error during manual RPC connection kill: {e}",
|
|
)
|
|
|
|
break
|
|
except Exception as e:
|
|
print(f"Error checking RPC addr {addr}: {e}")
|
|
|
|
if all_free:
|
|
print("All RNS ports/sockets are free.")
|
|
break
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
if not all_free:
|
|
await asyncio.sleep(2)
|
|
for addr, family_str in rpc_addrs:
|
|
if (
|
|
family_str == "AF_UNIX"
|
|
and isinstance(addr, str)
|
|
and addr.startswith("\0")
|
|
):
|
|
with contextlib.suppress(Exception):
|
|
self._force_close_abstract_unix_addr(addr)
|
|
|
|
last_check_all_free = True
|
|
for addr, family_str in rpc_addrs:
|
|
try:
|
|
family = (
|
|
socket.AF_INET
|
|
if family_str == "AF_INET"
|
|
else socket.AF_UNIX
|
|
)
|
|
s = socket.socket(family, socket.SOCK_STREAM)
|
|
try:
|
|
s.bind(addr)
|
|
s.close()
|
|
except OSError:
|
|
s.close()
|
|
is_abstract = (
|
|
family == socket.AF_UNIX
|
|
and isinstance(addr, str)
|
|
and addr.startswith("\0")
|
|
)
|
|
if is_abstract:
|
|
abstract_unix_addr_in_use_after_wait = True
|
|
continue
|
|
last_check_all_free = False
|
|
break
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
if not last_check_all_free:
|
|
raise OSError(
|
|
"Timeout waiting for RNS ports to be released. Cannot restart.",
|
|
)
|
|
print("RNS ports finally free after last-second check.")
|
|
|
|
gc.collect()
|
|
|
|
if hasattr(RNS.Reticulum, "_Reticulum__instance"):
|
|
RNS.Reticulum._Reticulum__instance = None
|
|
|
|
switched_instance_name = None
|
|
instance_restore_name = None
|
|
if abstract_unix_addr_in_use_after_wait:
|
|
stored_instance_name = self._read_reticulum_instance_name()
|
|
stable_base = ReticulumMeshChat._strip_reload_instance_suffix(
|
|
stored_instance_name,
|
|
)
|
|
instance_restore_name = (
|
|
stable_base if stable_base is not None else "default"
|
|
)
|
|
switched_instance_name = (
|
|
f"{instance_restore_name}-reload-{os.getpid()}-{int(time.time())}"
|
|
)
|
|
self._write_reticulum_instance_name(switched_instance_name)
|
|
print(
|
|
"Abstract UNIX RPC address remained busy. "
|
|
f"Retrying with temporary instance_name={switched_instance_name}",
|
|
)
|
|
|
|
self.running = True
|
|
await self._send_rns_reload_status(
|
|
"starting-services",
|
|
"Starting identity services again...",
|
|
)
|
|
try:
|
|
self.setup_identity(identity_to_restore)
|
|
finally:
|
|
if switched_instance_name:
|
|
self._write_reticulum_instance_name(instance_restore_name)
|
|
self._mark_network_ready()
|
|
self._finish_deferred_startup_services()
|
|
await self._send_rns_reload_status(
|
|
"done",
|
|
"RNS reload complete.",
|
|
level="success",
|
|
in_progress=False,
|
|
)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Hot reload failed: {e}")
|
|
|
|
traceback.print_exc()
|
|
await self._send_rns_reload_status(
|
|
"failed",
|
|
f"RNS reload failed: {e!s}",
|
|
level="error",
|
|
in_progress=False,
|
|
)
|
|
|
|
# Try to recover if possible without wiping storage.
|
|
if not hasattr(self, "reticulum") and identity_to_restore is not None:
|
|
try:
|
|
self.setup_identity(identity_to_restore)
|
|
self._mark_network_ready()
|
|
self._finish_deferred_startup_services()
|
|
return False
|
|
except Exception as recover_exc:
|
|
self._mark_network_degraded(
|
|
f"RNS reload failed and recovery failed: {recover_exc}",
|
|
)
|
|
else:
|
|
self._mark_network_degraded(f"RNS reload failed: {e}")
|
|
|
|
return False
|
|
|
|
async def hotswap_identity(self, identity_hash, keep_alive=False):
|
|
async with self._identity_hotswap_lock:
|
|
return await self._hotswap_identity_locked(
|
|
identity_hash,
|
|
keep_alive=keep_alive,
|
|
)
|
|
|
|
async def _hotswap_identity_locked(self, identity_hash, keep_alive=False):
|
|
old_identity = self.identity
|
|
|
|
main_identity_file = self.identity_file_path or os.path.join(
|
|
self.storage_dir,
|
|
"identity",
|
|
)
|
|
backup_identity_file = main_identity_file + ".bak"
|
|
backup_created = False
|
|
|
|
try:
|
|
canonical = normalize_identity_storage_hash(identity_hash)
|
|
if not canonical:
|
|
raise ValueError("Invalid identity hash")
|
|
# load the new identity
|
|
identities_root = os.path.join(self.storage_dir, "identities")
|
|
identity_dir = os.path.join(identities_root, canonical)
|
|
identity_file = os.path.join(identity_dir, "identity")
|
|
if not is_path_within_dir(identity_dir, identities_root):
|
|
raise ValueError("Invalid identity hash")
|
|
if not os.path.exists(identity_file):
|
|
raise ValueError("Identity file not found")
|
|
|
|
# Validate that the identity file can be loaded
|
|
new_identity = RNS.Identity.from_file(identity_file)
|
|
if not new_identity:
|
|
raise ValueError("Identity file corrupted or invalid")
|
|
|
|
# 1. Backup current identity file
|
|
if os.path.exists(main_identity_file):
|
|
shutil.copy2(main_identity_file, backup_identity_file)
|
|
backup_created = True
|
|
|
|
# 2. teardown old identity if not keeping alive
|
|
if not keep_alive:
|
|
self.teardown_identity()
|
|
# Give a moment for destinations to clear from transport
|
|
await asyncio.sleep(2)
|
|
else:
|
|
# Even when keeping the old context alive, never reuse mesh links
|
|
# that may already be identified as the prior identity.
|
|
self._clear_mesh_link_caches()
|
|
|
|
# 3. update main identity file
|
|
shutil.copy2(identity_file, main_identity_file)
|
|
|
|
# 4. setup new identity
|
|
self.running = True
|
|
# setup_identity initializes context if needed and sets it as current
|
|
self.setup_identity(new_identity)
|
|
|
|
# 5. broadcast update to clients
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "identity_switched",
|
|
"identity_hash": canonical,
|
|
"display_name": (
|
|
self.config.display_name.get()
|
|
if hasattr(self, "config")
|
|
else "Unknown"
|
|
),
|
|
"requires_reauth": bool(self.auth_enabled),
|
|
},
|
|
),
|
|
)
|
|
|
|
# Clean up backup on success
|
|
if backup_created and os.path.exists(backup_identity_file):
|
|
os.remove(backup_identity_file)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Hotswap failed: {e}")
|
|
traceback.print_exc()
|
|
|
|
# RECOVERY: Try to switch back to last identity
|
|
try:
|
|
print("Attempting to restore previous identity...")
|
|
if backup_created and os.path.exists(backup_identity_file):
|
|
shutil.copy2(backup_identity_file, main_identity_file)
|
|
os.remove(backup_identity_file)
|
|
|
|
self.running = True
|
|
if old_identity:
|
|
self.setup_identity(old_identity)
|
|
except Exception as recovery_err:
|
|
print(f"Recovery failed: {recovery_err}")
|
|
traceback.print_exc()
|
|
|
|
# FINAL FAILSAFE: Create a brand new identity
|
|
try:
|
|
print(
|
|
"CRITICAL: Restoration of previous identity failed. Creating a brand new emergency identity...",
|
|
)
|
|
new_id_data = self.create_identity(
|
|
display_name="Emergency Recovery",
|
|
)
|
|
new_id_hash = new_id_data["hash"]
|
|
|
|
# Try to load the newly created identity
|
|
emergency_identity_file = os.path.join(
|
|
self.storage_dir,
|
|
"identities",
|
|
new_id_hash,
|
|
"identity",
|
|
)
|
|
emergency_id = RNS.Identity.from_file(emergency_identity_file)
|
|
|
|
if emergency_id:
|
|
# Copy to main identity file
|
|
shutil.copy2(emergency_identity_file, main_identity_file)
|
|
self.running = True
|
|
self.setup_identity(emergency_id)
|
|
print(f"Emergency identity created and loaded: {new_id_hash}")
|
|
else:
|
|
raise RuntimeError(
|
|
"Failed to load newly created emergency identity",
|
|
)
|
|
|
|
except Exception as final_err:
|
|
print(
|
|
f"ULTIMATE FAILURE: Could not even create emergency identity: {final_err}",
|
|
)
|
|
traceback.print_exc()
|
|
|
|
return False
|
|
|
|
def backup_identity(self):
|
|
return self.identity_manager.backup_identity(self.identity)
|
|
|
|
def backup_identity_base32(self) -> str:
|
|
return self.identity_manager.backup_identity_base32(self.identity)
|
|
|
|
def list_identities(self):
|
|
return self.identity_manager.list_identities(
|
|
self.identity.hash.hex()
|
|
if hasattr(self, "identity") and self.identity
|
|
else None,
|
|
)
|
|
|
|
def create_identity(self, display_name=None):
|
|
return self.identity_manager.create_identity(display_name)
|
|
|
|
def _evict_cached_identity_context(self, identity_hash: str) -> None:
|
|
canonical = normalize_identity_storage_hash(identity_hash)
|
|
if not canonical:
|
|
return
|
|
ctx = self.contexts.pop(canonical, None)
|
|
if ctx is not None:
|
|
with contextlib.suppress(Exception):
|
|
ctx.teardown()
|
|
self._propagation_sync_metrics.pop(canonical, None)
|
|
coord = getattr(self, "_auto_resend_coordinator", None)
|
|
if coord is not None:
|
|
with contextlib.suppress(Exception):
|
|
coord.drop_identity(canonical)
|
|
|
|
def delete_identity(self, identity_hash):
|
|
current_hash = (
|
|
self.identity.hash.hex()
|
|
if hasattr(self, "identity") and self.identity
|
|
else None
|
|
)
|
|
deleted = self.identity_manager.delete_identity(identity_hash, current_hash)
|
|
if deleted:
|
|
self._evict_cached_identity_context(identity_hash)
|
|
return deleted
|
|
|
|
def restore_identity_from_bytes(
|
|
self,
|
|
identity_bytes: bytes,
|
|
display_name: str | None = None,
|
|
):
|
|
return self.identity_manager.restore_identity_from_bytes(
|
|
identity_bytes,
|
|
display_name=display_name,
|
|
)
|
|
|
|
def restore_identity_from_base32(
|
|
self,
|
|
base32_value: str,
|
|
display_name: str | None = None,
|
|
):
|
|
return self.identity_manager.restore_identity_from_base32(
|
|
base32_value,
|
|
display_name=display_name,
|
|
)
|
|
|
|
def update_identity_metadata_cache(self):
|
|
if not hasattr(self, "identity") or not self.identity:
|
|
return
|
|
|
|
identity_hash = self.identity.hash.hex()
|
|
metadata = {
|
|
"display_name": self.config.display_name.get(),
|
|
"icon_name": self.config.lxmf_user_icon_name.get(),
|
|
"icon_foreground_colour": self.config.lxmf_user_icon_foreground_colour.get(),
|
|
"icon_background_colour": self.config.lxmf_user_icon_background_colour.get(),
|
|
"lxmf_address": self.config.lxmf_address_hash.get(),
|
|
"lxst_address": self.config.lxst_address_hash.get(),
|
|
}
|
|
self.identity_manager.update_metadata_cache(identity_hash, metadata)
|
|
|
|
def _run_startup_auto_recovery(self):
|
|
try:
|
|
self.database.initialize()
|
|
print("Attempting SQLite auto recovery on startup...")
|
|
actions = []
|
|
actions.append(
|
|
{
|
|
"step": "wal_checkpoint",
|
|
"result": self.database.provider.checkpoint(),
|
|
},
|
|
)
|
|
actions.append(
|
|
{
|
|
"step": "integrity_check",
|
|
"result": self.database.provider.integrity_check(),
|
|
},
|
|
)
|
|
self.database.provider.vacuum()
|
|
self.database._tune_sqlite_pragmas()
|
|
actions.append(
|
|
{
|
|
"step": "quick_check_after",
|
|
"result": self.database.provider.quick_check(),
|
|
},
|
|
)
|
|
print(f"Auto recovery completed: {actions}")
|
|
finally:
|
|
try:
|
|
self.database.close_all()
|
|
except Exception as e:
|
|
print(f"Failed to close database during recovery: {e}")
|
|
|
|
# gets app version from the synchronized Python version helper
|
|
@staticmethod
|
|
def get_app_version() -> str:
|
|
return app_version
|
|
|
|
@staticmethod
|
|
def get_build_meta() -> dict:
|
|
"""Baked git commit / channel from meshchatx.src.build_meta."""
|
|
try:
|
|
from meshchatx.src import build_meta as _build_meta
|
|
|
|
return dict(_build_meta.as_dict(app_version))
|
|
except Exception:
|
|
return {
|
|
"git_commit": "",
|
|
"git_commit_short": "",
|
|
"build_channel": "local",
|
|
"is_dev_build": False,
|
|
"display_version": app_version,
|
|
}
|
|
|
|
def _api_reticulum_config_path(self) -> str | None:
|
|
r = getattr(self, "reticulum", None)
|
|
if r is not None:
|
|
p = getattr(r, "configpath", None)
|
|
if p:
|
|
return str(p)
|
|
rd = self._normalize_reticulum_config_dir(
|
|
getattr(self, "reticulum_config_dir", None),
|
|
)
|
|
if rd:
|
|
return os.path.join(rd, "config")
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_package_version(package_name: str, default: str = "unknown") -> str:
|
|
"""Resolve an installed distribution version for About /app/info.
|
|
|
|
cx_Freeze and similar bundles often omit .dist-info; fall back to module
|
|
attributes and known submodule layouts (e.g. websockets.version).
|
|
"""
|
|
try:
|
|
from packaging.utils import canonicalize_name as _canonicalize_name
|
|
except Exception:
|
|
|
|
def _canonicalize_name(name: str) -> str:
|
|
return str(name).strip().lower().replace("_", "-")
|
|
|
|
def _from_metadata(dist_name: str) -> str | None:
|
|
for candidate in dict.fromkeys((dist_name, _canonicalize_name(dist_name))):
|
|
try:
|
|
v = importlib.metadata.version(candidate)
|
|
if v:
|
|
return str(v)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
v = importlib.metadata.distribution(candidate).version
|
|
if v:
|
|
return str(v)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
resolved = _from_metadata(package_name)
|
|
if resolved:
|
|
return resolved
|
|
|
|
module_name = package_name.replace("-", "_")
|
|
top_level = module_name.split(".")[0]
|
|
|
|
try:
|
|
for dist_name in importlib.metadata.packages_distributions().get(
|
|
top_level,
|
|
(),
|
|
):
|
|
resolved = _from_metadata(dist_name)
|
|
if resolved:
|
|
return resolved
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
module = importlib.import_module(module_name)
|
|
ver = getattr(module, "__version__", None)
|
|
if ver:
|
|
return str(ver)
|
|
except Exception:
|
|
pass
|
|
|
|
if top_level == "websockets":
|
|
try:
|
|
from websockets.version import version as websockets_semver
|
|
|
|
if websockets_semver:
|
|
return str(websockets_semver)
|
|
except Exception:
|
|
pass
|
|
|
|
if top_level == "lxmfy":
|
|
try:
|
|
vmod = importlib.import_module("lxmfy.__version__")
|
|
ver = getattr(vmod, "__version__", None)
|
|
if ver:
|
|
return str(ver)
|
|
except Exception:
|
|
pass
|
|
|
|
if top_level == "rns_filesync":
|
|
try:
|
|
module = importlib.import_module("rns_filesync")
|
|
ver = getattr(module, "__version__", None)
|
|
if ver:
|
|
return str(ver)
|
|
except Exception:
|
|
pass
|
|
|
|
embedded_specs: dict[str, tuple[str, str]] = {
|
|
"aiohttp": ("aiohttp", "__version__"),
|
|
"aiohttp-session": ("aiohttp_session", "__version__"),
|
|
"cryptography": ("cryptography", "__version__"),
|
|
"psutil": ("psutil", "__version__"),
|
|
"websockets": ("websockets", "__version__"),
|
|
"bcrypt": ("bcrypt", "__version__"),
|
|
"ply": ("ply", "__version__"),
|
|
"lxmfy": ("lxmfy", "__version__"),
|
|
"rns-filesync": ("rns_filesync", "__version__"),
|
|
"rns_filesync": ("rns_filesync", "__version__"),
|
|
}
|
|
if package_name in embedded_specs:
|
|
mod_name, attr = embedded_specs[package_name]
|
|
try:
|
|
module = importlib.import_module(mod_name)
|
|
ver = getattr(module, attr, None)
|
|
if ver:
|
|
return str(ver)
|
|
except Exception:
|
|
pass
|
|
if package_name == "ply":
|
|
try:
|
|
lex = importlib.import_module("ply.lex")
|
|
ver = getattr(lex, "VERSION", None)
|
|
if ver:
|
|
return str(ver)
|
|
except Exception:
|
|
pass
|
|
|
|
return default
|
|
|
|
@staticmethod
|
|
def parse_discovery_patterns(value):
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
value = value.replace("\n", ",")
|
|
return [part.strip() for part in value.split(",") if part.strip()]
|
|
if isinstance(value, (list, tuple)):
|
|
return [str(part).strip() for part in value if str(part).strip()]
|
|
text_value = str(value).strip()
|
|
return [text_value] if text_value else []
|
|
|
|
@staticmethod
|
|
def sanitize_discovery_patterns(
|
|
value,
|
|
max_patterns: int = 128,
|
|
max_pattern_length: int = 128,
|
|
):
|
|
sanitized = []
|
|
seen = set()
|
|
for pattern in ReticulumMeshChat.parse_discovery_patterns(value):
|
|
cleaned = (
|
|
pattern.replace("\r", "").replace("\n", "").replace(",", "").strip()
|
|
)
|
|
if not cleaned:
|
|
continue
|
|
cleaned = "".join(ch for ch in cleaned if ch.isprintable()).strip()
|
|
if not cleaned:
|
|
continue
|
|
if len(cleaned) > max_pattern_length:
|
|
cleaned = cleaned[:max_pattern_length]
|
|
lowered = cleaned.lower()
|
|
if lowered in seen:
|
|
continue
|
|
seen.add(lowered)
|
|
sanitized.append(cleaned)
|
|
if len(sanitized) >= max_patterns:
|
|
break
|
|
return sanitized
|
|
|
|
@staticmethod
|
|
def _reticulum_yes_no_preference(value, *, default):
|
|
if value is None or value == "":
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
s = str(value).strip().lower()
|
|
if s in ("false", "no", "0", "n", "off"):
|
|
return False
|
|
if s in ("true", "yes", "1", "y", "on"):
|
|
return True
|
|
return default
|
|
|
|
@staticmethod
|
|
def _bootstrap_only_request_yes_no(value):
|
|
if value is None or value == "":
|
|
return None
|
|
if isinstance(value, bool):
|
|
return "yes" if value else "no"
|
|
s = str(value).strip().lower()
|
|
if s in ("true", "yes", "1", "y", "on"):
|
|
return "yes"
|
|
if s in ("false", "no", "0", "n", "off"):
|
|
return "no"
|
|
return None
|
|
|
|
@staticmethod
|
|
def apply_bootstrap_only_to_interface(
|
|
interface_details,
|
|
data,
|
|
default_enabled,
|
|
*,
|
|
updating_existing=False,
|
|
):
|
|
if "bootstrap_only" in data:
|
|
yn = ReticulumMeshChat._bootstrap_only_request_yes_no(
|
|
data.get("bootstrap_only"),
|
|
)
|
|
if yn == "yes":
|
|
interface_details["bootstrap_only"] = "yes"
|
|
elif yn == "no":
|
|
interface_details["bootstrap_only"] = "no"
|
|
else:
|
|
interface_details.pop("bootstrap_only", None)
|
|
return
|
|
if updating_existing:
|
|
return
|
|
if default_enabled:
|
|
interface_details["bootstrap_only"] = "yes"
|
|
else:
|
|
interface_details.pop("bootstrap_only", None)
|
|
|
|
@staticmethod
|
|
def _to_jsonable(obj):
|
|
if isinstance(obj, bytes):
|
|
return obj.hex()
|
|
if isinstance(obj, dict):
|
|
return {k: ReticulumMeshChat._to_jsonable(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [ReticulumMeshChat._to_jsonable(v) for v in obj]
|
|
return obj
|
|
|
|
def _get_interface_stats_payload(self) -> dict:
|
|
empty: dict = {"interfaces": []}
|
|
reticulum = getattr(self, "reticulum", None)
|
|
if not reticulum:
|
|
return empty
|
|
try:
|
|
raw = reticulum.get_interface_stats()
|
|
except Exception as exc:
|
|
logger.warning("Failed to get interface stats: %s", exc)
|
|
return empty
|
|
if not isinstance(raw, dict):
|
|
return empty
|
|
payload = self._to_jsonable(raw)
|
|
for interface in payload.get("interfaces") or []:
|
|
if isinstance(interface, dict) and "short_name" in interface:
|
|
interface["interface_name"] = interface["short_name"]
|
|
return payload
|
|
|
|
@staticmethod
|
|
def discovery_filter_candidates(interface):
|
|
if not isinstance(interface, dict):
|
|
return [str(interface)]
|
|
candidates = []
|
|
for key in (
|
|
"name",
|
|
"type",
|
|
"reachable_on",
|
|
"target_host",
|
|
"remote",
|
|
"listen_ip",
|
|
"port",
|
|
"target_port",
|
|
"listen_port",
|
|
"discovery_hash",
|
|
"transport_id",
|
|
"network_id",
|
|
"network_name",
|
|
"ifac_netname",
|
|
):
|
|
value = interface.get(key)
|
|
if value is not None and value != "":
|
|
candidates.append(str(value))
|
|
|
|
host = (
|
|
interface.get("reachable_on")
|
|
or interface.get("target_host")
|
|
or interface.get("remote")
|
|
or interface.get("listen_ip")
|
|
)
|
|
port = (
|
|
interface.get("port")
|
|
or interface.get("target_port")
|
|
or interface.get("listen_port")
|
|
)
|
|
if host and port:
|
|
candidates.append(f"{host}:{port}")
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def matches_discovery_pattern(patterns, interface):
|
|
if not patterns:
|
|
return False
|
|
candidates = [
|
|
value.lower()
|
|
for value in ReticulumMeshChat.discovery_filter_candidates(interface)
|
|
]
|
|
for pattern in patterns:
|
|
normalized_pattern = str(pattern).lower()
|
|
for candidate in candidates:
|
|
if fnmatch.fnmatchcase(candidate, normalized_pattern):
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def normalize_discovered_ifac_fields(interfaces):
|
|
"""Surface IFAC fields from discovery announces in a frontend-friendly shape.
|
|
|
|
RNS publishes IFAC values in discovered interface dicts as
|
|
ifac_netname and ifac_netkey (when the publishing interface
|
|
sets publish_ifac = yes). The Reticulum config file uses
|
|
network_name / passphrase instead. This helper keeps the raw
|
|
RNS keys for backwards compatibility but also exposes the canonical
|
|
config-style aliases (network_name and passphrase) and ensures
|
|
the optional config_entry blob is always a string when present.
|
|
|
|
Returns the list with new keys added; missing values become None
|
|
so the frontend can render placeholders consistently.
|
|
"""
|
|
if not isinstance(interfaces, list):
|
|
return interfaces
|
|
normalized = []
|
|
for entry in interfaces:
|
|
if not isinstance(entry, dict):
|
|
normalized.append(entry)
|
|
continue
|
|
updated = dict(entry)
|
|
|
|
netname = updated.get("ifac_netname")
|
|
netkey = updated.get("ifac_netkey")
|
|
config_entry = updated.get("config_entry")
|
|
|
|
if isinstance(netname, bytes):
|
|
try:
|
|
netname = netname.decode("utf-8")
|
|
except Exception:
|
|
netname = netname.hex()
|
|
if isinstance(netkey, bytes):
|
|
try:
|
|
netkey = netkey.decode("utf-8")
|
|
except Exception:
|
|
netkey = netkey.hex()
|
|
if isinstance(config_entry, bytes):
|
|
try:
|
|
config_entry = config_entry.decode("utf-8")
|
|
except Exception:
|
|
config_entry = None
|
|
|
|
updated["ifac_netname"] = netname or None
|
|
updated["ifac_netkey"] = netkey or None
|
|
updated["config_entry"] = config_entry or None
|
|
updated["network_name"] = updated["ifac_netname"]
|
|
updated["passphrase"] = updated["ifac_netkey"]
|
|
updated["publish_ifac"] = bool(
|
|
updated["ifac_netname"] or updated["ifac_netkey"],
|
|
)
|
|
normalized.append(updated)
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def filter_discovered_interfaces(
|
|
interfaces,
|
|
whitelist_patterns,
|
|
blacklist_patterns,
|
|
):
|
|
if not isinstance(interfaces, list):
|
|
return interfaces
|
|
whitelist = ReticulumMeshChat.sanitize_discovery_patterns(whitelist_patterns)
|
|
blacklist = ReticulumMeshChat.sanitize_discovery_patterns(blacklist_patterns)
|
|
return [
|
|
interface
|
|
for interface in interfaces
|
|
if (
|
|
(
|
|
not whitelist
|
|
or ReticulumMeshChat.matches_discovery_pattern(whitelist, interface)
|
|
)
|
|
and not ReticulumMeshChat.matches_discovery_pattern(
|
|
blacklist,
|
|
interface,
|
|
)
|
|
)
|
|
]
|
|
|
|
def _default_announce_fetch_limit(self, aspect):
|
|
ctx = self.current_context
|
|
if not ctx or not ctx.config:
|
|
return 2500
|
|
keys = {
|
|
"lxmf.delivery": ctx.config.announce_fetch_limit_lxmf_delivery,
|
|
"nomadnetwork.node": ctx.config.announce_fetch_limit_nomadnetwork_node,
|
|
"lxmf.propagation": ctx.config.announce_fetch_limit_lxmf_propagation,
|
|
"lxst.telephony": ctx.config.announce_fetch_limit_lxmf_delivery,
|
|
}
|
|
cfg = keys.get(aspect)
|
|
if cfg is None:
|
|
return 2500
|
|
v = cfg.get()
|
|
if v is None or v < 1:
|
|
return 2500
|
|
return min(int(v), 100_000)
|
|
|
|
def get_lxst_version(self) -> str:
|
|
return self.get_package_version("lxst", getattr(LXST, "__version__", "unknown"))
|
|
|
|
async def announce_loop(self, session_id, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
gc_counter = 0
|
|
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
now = time.time()
|
|
should_announce = interval_action_due(
|
|
ctx.config.auto_announce_enabled.get(),
|
|
ctx.config.last_announced_at.get(),
|
|
ctx.config.auto_announce_interval_seconds.get(),
|
|
now,
|
|
)
|
|
|
|
# announce
|
|
if should_announce:
|
|
await self.announce(context=ctx)
|
|
|
|
# also announce forwarding aliases if any
|
|
if ctx.forwarding_manager:
|
|
await asyncio.to_thread(ctx.forwarding_manager.announce_aliases)
|
|
|
|
gc_counter += 1
|
|
if gc_counter >= 300:
|
|
gc_counter = 0
|
|
try:
|
|
await asyncio.to_thread(self.memory_pressure.run_periodic_cleanup)
|
|
except Exception as exc:
|
|
print(f"[memory_pressure] periodic cleanup error: {exc}")
|
|
# Python 3.14+ incremental GC: with threshold[2]==0 full gen2
|
|
# collections are never scheduled automatically, so force one.
|
|
if sys.version_info >= (3, 14) and gc.get_threshold()[2] == 0:
|
|
gc.collect(2)
|
|
else:
|
|
gc.collect()
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
async def _memory_diag_snapshot_loop(self):
|
|
if not self._mem_diag:
|
|
return
|
|
while self.running and self._mem_diag.enabled:
|
|
try:
|
|
await asyncio.to_thread(self._mem_diag.snapshot)
|
|
n = self._mem_diag.total_snapshots
|
|
if n % 12 == 0:
|
|
report = await asyncio.to_thread(
|
|
self._mem_diag.diff_snapshots,
|
|
top_n=10,
|
|
)
|
|
if report:
|
|
growth = sum(r["size_mib"] for r in report)
|
|
print(
|
|
f"[mem_diag] Snapshot #{n}: +{growth:.2f} MiB "
|
|
f"growth in top {len(report)} sites",
|
|
)
|
|
except Exception as exc:
|
|
print(f"[mem_diag] Snapshot error: {exc}")
|
|
await asyncio.sleep(300) # every 5 minutes
|
|
|
|
# automatically syncs propagation nodes based on user config
|
|
async def announce_sync_propagation_nodes(self, session_id, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
router = ctx.message_router
|
|
sync_start_time = None
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
auto_sync_interval_seconds = ctx.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
|
|
last_synced_at = (
|
|
ctx.config.lxmf_preferred_propagation_node_last_synced_at.get()
|
|
)
|
|
outbound_node = router.get_outbound_propagation_node() if router else None
|
|
should_sync = outbound_node is not None and interval_action_due(
|
|
auto_sync_interval_seconds is not None
|
|
and auto_sync_interval_seconds > 0,
|
|
last_synced_at,
|
|
auto_sync_interval_seconds,
|
|
time.time(),
|
|
)
|
|
|
|
if should_sync and sync_start_time is None:
|
|
started = await self.sync_propagation_nodes(context=ctx)
|
|
if started:
|
|
sync_start_time = time.monotonic()
|
|
|
|
if sync_start_time is not None and router:
|
|
state = router.propagation_transfer_state
|
|
elapsed = time.monotonic() - sync_start_time
|
|
path_stuck = (
|
|
state == router.PR_PATH_REQUESTED
|
|
and outbound_node is not None
|
|
and not RNS.Transport.has_path(outbound_node)
|
|
and elapsed > 45.0
|
|
)
|
|
if propagation_sync_is_terminal(state):
|
|
if state != router.PR_IDLE:
|
|
self.stop_propagation_node_sync(context=ctx)
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
router.propagation_transfer_progress = 0.0
|
|
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(
|
|
int(time.time()),
|
|
)
|
|
await self.send_config_to_websocket_clients(context=ctx)
|
|
sync_start_time = None
|
|
elif path_stuck or elapsed > 120:
|
|
self.stop_propagation_node_sync(context=ctx)
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(
|
|
int(time.time()),
|
|
)
|
|
await self.send_config_to_websocket_clients(context=ctx)
|
|
sync_start_time = None
|
|
|
|
# wait 1 second before next loop
|
|
await asyncio.sleep(1)
|
|
|
|
async def crawler_loop(self, session_id, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
try:
|
|
if ctx.config.crawler_enabled.get():
|
|
# Proactively queue any known nodes from the database that haven't been queued yet
|
|
# get known propagation nodes from database
|
|
known_nodes = ctx.database.announces.get_announces(
|
|
aspect="nomadnetwork.node",
|
|
)
|
|
for node in known_nodes:
|
|
if (
|
|
not self.running
|
|
or not ctx.running
|
|
or ctx.session_id != session_id
|
|
):
|
|
break
|
|
self.queue_crawler_task(
|
|
node["destination_hash"],
|
|
ctx.config.nomad_default_page_path.get()
|
|
or "/page/index.mu",
|
|
context=ctx,
|
|
)
|
|
|
|
# process pending or failed tasks
|
|
# ensure we handle potential string comparison issues in SQLite
|
|
tasks = ctx.database.misc.get_pending_or_failed_crawl_tasks(
|
|
max_retries=ctx.config.crawler_max_retries.get(),
|
|
max_concurrent=ctx.config.crawler_max_concurrent.get(),
|
|
)
|
|
|
|
# process tasks concurrently up to the limit
|
|
if tasks and self.running and ctx.running:
|
|
await asyncio.gather(
|
|
*[
|
|
self.process_crawler_task(task, context=ctx)
|
|
for task in tasks
|
|
],
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"Error in crawler loop for {ctx.identity_hash}: {e}")
|
|
|
|
# wait 30 seconds before checking again
|
|
for _ in range(30):
|
|
if not self.running or not ctx.running or ctx.session_id != session_id:
|
|
return
|
|
await asyncio.sleep(1)
|
|
|
|
async def process_crawler_task(self, task, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# mark as crawling
|
|
task_id = task["id"]
|
|
ctx.database.misc.update_crawl_task(
|
|
task_id,
|
|
status="crawling",
|
|
last_retry_at=datetime.now(UTC),
|
|
)
|
|
|
|
destination_hash = task["destination_hash"]
|
|
page_path = task["page_path"]
|
|
|
|
print(
|
|
f"Crawler: Archiving {destination_hash}:{page_path} (Attempt {task['retry_count'] + 1})",
|
|
)
|
|
|
|
# completion event
|
|
done_event = asyncio.Event()
|
|
success = [False]
|
|
content_received = [None]
|
|
failure_reason = ["timeout"]
|
|
|
|
def on_success(content):
|
|
success[0] = True
|
|
content_received[0] = content
|
|
done_event.set()
|
|
|
|
def on_failure(reason):
|
|
failure_reason[0] = reason
|
|
done_event.set()
|
|
|
|
def on_progress(progress):
|
|
pass
|
|
|
|
# start downloader
|
|
downloader = NomadnetPageDownloader(
|
|
destination_hash=bytes.fromhex(destination_hash),
|
|
page_path=page_path,
|
|
data=None,
|
|
on_page_download_success=on_success,
|
|
on_page_download_failure=on_failure,
|
|
on_progress_update=on_progress,
|
|
timeout=120,
|
|
reticulum=getattr(self, "reticulum", None),
|
|
)
|
|
|
|
try:
|
|
# use a dedicated task for the download so we can wait for it
|
|
download_task = asyncio.create_task(downloader.download())
|
|
|
|
# wait for completion event
|
|
try:
|
|
await asyncio.wait_for(done_event.wait(), timeout=180)
|
|
except TimeoutError:
|
|
failure_reason[0] = "timeout"
|
|
downloader.cancel()
|
|
|
|
await download_task
|
|
except Exception as e:
|
|
print(
|
|
f"Crawler: Error during download for {destination_hash}:{page_path}: {e}",
|
|
)
|
|
failure_reason[0] = str(e)
|
|
done_event.set()
|
|
|
|
if success[0]:
|
|
print(f"Crawler: Successfully archived {destination_hash}:{page_path}")
|
|
self.archive_page(
|
|
destination_hash,
|
|
page_path,
|
|
content_received[0],
|
|
is_manual=False,
|
|
context=ctx,
|
|
)
|
|
ctx.database.misc.update_crawl_task(
|
|
task_id,
|
|
status="completed",
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
else:
|
|
print(
|
|
f"Crawler: Failed to archive {destination_hash}:{page_path} - {failure_reason[0]}",
|
|
)
|
|
retry_count = task["retry_count"] + 1
|
|
|
|
# calculate next retry time
|
|
retry_delay = ctx.config.crawler_retry_delay_seconds.get()
|
|
# simple backoff
|
|
backoff_delay = retry_delay * (2 ** (retry_count - 1))
|
|
next_retry_at = datetime.now(UTC) + timedelta(seconds=backoff_delay)
|
|
|
|
ctx.database.misc.update_crawl_task(
|
|
task_id,
|
|
status="failed",
|
|
retry_count=retry_count,
|
|
next_retry_at=next_retry_at,
|
|
updated_at=datetime.now(UTC),
|
|
)
|
|
|
|
# uses the provided destination hash as the active propagation node
|
|
def set_active_propagation_node(self, destination_hash: str | None, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.message_router:
|
|
return
|
|
|
|
# Always cancel an in-flight sync before switching nodes so we don't
|
|
# orphan a transfer or leave the router in a stuck state.
|
|
self.stop_propagation_node_sync(context=ctx)
|
|
|
|
# set outbound propagation node
|
|
if destination_hash is not None and destination_hash != "":
|
|
try:
|
|
destination_hash_bytes = bytes.fromhex(destination_hash)
|
|
ctx.message_router.set_outbound_propagation_node(
|
|
destination_hash_bytes,
|
|
)
|
|
with contextlib.suppress(Exception):
|
|
RNS.Transport.request_path(destination_hash_bytes)
|
|
except Exception:
|
|
# failed to set propagation node, clear it to ensure we don't use an old one by mistake
|
|
self.remove_active_propagation_node(context=ctx)
|
|
|
|
# stop using propagation node
|
|
else:
|
|
self.remove_active_propagation_node(context=ctx)
|
|
|
|
# stops the in progress propagation node sync
|
|
def stop_propagation_node_sync(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.message_router:
|
|
return
|
|
router = ctx.message_router
|
|
with contextlib.suppress(Exception):
|
|
router.cancel_propagation_node_requests()
|
|
# cancel_propagation_node_requests resets via acknowledge_sync_completion,
|
|
# but a blocked RNS.Identity.recall can leave the router in an active state.
|
|
with contextlib.suppress(Exception):
|
|
active_states = {
|
|
router.PR_PATH_REQUESTED,
|
|
router.PR_LINK_ESTABLISHING,
|
|
router.PR_LINK_ESTABLISHED,
|
|
router.PR_REQUEST_SENT,
|
|
router.PR_RECEIVING,
|
|
router.PR_RESPONSE_RECEIVED,
|
|
}
|
|
if router.propagation_transfer_state in active_states:
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
router.propagation_transfer_progress = 0.0
|
|
|
|
async def _request_propagation_node_messages(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.message_router:
|
|
return
|
|
|
|
router = ctx.message_router
|
|
|
|
def _request():
|
|
try:
|
|
router.request_messages_from_propagation_node(ctx.identity)
|
|
except (EOFError, BrokenPipeError, ConnectionResetError, OSError):
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
router.propagation_transfer_progress = 0.0
|
|
except Exception:
|
|
logging.getLogger("meshchatx").exception(
|
|
"Propagation node message request failed",
|
|
)
|
|
|
|
await asyncio.to_thread(_request)
|
|
await self.send_config_to_websocket_clients(context=ctx)
|
|
|
|
def _get_propagation_sync_metrics(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return None
|
|
|
|
key = ctx.identity_hash
|
|
if key not in self._propagation_sync_metrics:
|
|
self._propagation_sync_metrics[key] = {
|
|
"started_at": None,
|
|
"baseline_total_messages": 0,
|
|
"baseline_delivered_messages": 0,
|
|
"messages_stored": 0,
|
|
"delivery_confirmations": 0,
|
|
"messages_hidden": 0,
|
|
}
|
|
|
|
return self._propagation_sync_metrics[key]
|
|
|
|
def _begin_propagation_sync_metrics(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
|
|
metrics = self._get_propagation_sync_metrics(context=ctx)
|
|
if metrics is None:
|
|
return
|
|
|
|
metrics["started_at"] = datetime.now(UTC).isoformat()
|
|
metrics["baseline_total_messages"] = ctx.database.messages.count_lxmf_messages()
|
|
metrics["baseline_delivered_messages"] = (
|
|
ctx.database.messages.count_lxmf_messages_by_state("delivered")
|
|
)
|
|
metrics["messages_stored"] = 0
|
|
metrics["delivery_confirmations"] = 0
|
|
metrics["messages_hidden"] = 0
|
|
|
|
def _collect_propagation_sync_metrics(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return {
|
|
"messages_stored": 0,
|
|
"delivery_confirmations": 0,
|
|
"messages_hidden": 0,
|
|
}
|
|
|
|
metrics = self._get_propagation_sync_metrics(context=ctx)
|
|
if metrics is None:
|
|
return {
|
|
"messages_stored": 0,
|
|
"delivery_confirmations": 0,
|
|
"messages_hidden": 0,
|
|
}
|
|
if metrics["started_at"] is None:
|
|
return {
|
|
"messages_stored": 0,
|
|
"delivery_confirmations": 0,
|
|
"messages_hidden": 0,
|
|
}
|
|
|
|
if not ctx.message_router:
|
|
return {
|
|
"messages_stored": 0,
|
|
"delivery_confirmations": 0,
|
|
"messages_hidden": 0,
|
|
}
|
|
|
|
messages_received = ctx.message_router.propagation_transfer_last_result or 0
|
|
current_total_messages = ctx.database.messages.count_lxmf_messages()
|
|
current_delivered_messages = ctx.database.messages.count_lxmf_messages_by_state(
|
|
"delivered",
|
|
)
|
|
|
|
messages_stored = max(
|
|
current_total_messages - metrics["baseline_total_messages"],
|
|
0,
|
|
)
|
|
delivery_confirmations = max(
|
|
current_delivered_messages - metrics["baseline_delivered_messages"],
|
|
0,
|
|
)
|
|
messages_hidden = max(
|
|
messages_received - messages_stored - delivery_confirmations,
|
|
0,
|
|
)
|
|
|
|
metrics["messages_stored"] = messages_stored
|
|
metrics["delivery_confirmations"] = delivery_confirmations
|
|
metrics["messages_hidden"] = messages_hidden
|
|
|
|
return {
|
|
"messages_stored": messages_stored,
|
|
"delivery_confirmations": delivery_confirmations,
|
|
"messages_hidden": messages_hidden,
|
|
}
|
|
|
|
# stops and removes the active propagation node
|
|
def remove_active_propagation_node(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
self.stop_propagation_node_sync(context=ctx)
|
|
if ctx.message_router:
|
|
ctx.message_router.outbound_propagation_node = None
|
|
# Force the transfer state back to idle so nothing remains stuck
|
|
# after the outbound node is removed.
|
|
with contextlib.suppress(Exception):
|
|
ctx.message_router.propagation_transfer_state = (
|
|
ctx.message_router.PR_IDLE
|
|
)
|
|
|
|
# enables or disables the local lxmf propagation node
|
|
def enable_local_propagation_node(self, enabled: bool = True, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.message_router:
|
|
return
|
|
try:
|
|
if enabled:
|
|
ctx.message_router.enable_propagation()
|
|
else:
|
|
ctx.message_router.disable_propagation()
|
|
except Exception:
|
|
print(
|
|
f"failed to enable or disable propagation node for {ctx.identity_hash}",
|
|
)
|
|
|
|
def stop_local_propagation_node(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
self.enable_local_propagation_node(False, context=ctx)
|
|
|
|
def restart_local_propagation_node(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
self.stop_local_propagation_node(context=ctx)
|
|
self.enable_local_propagation_node(True, context=ctx)
|
|
|
|
def get_local_propagation_node_stats(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return None
|
|
|
|
router = ctx.message_router
|
|
if not router:
|
|
return None
|
|
|
|
is_running = bool(getattr(router, "propagation_node", False))
|
|
stats = None
|
|
if is_running:
|
|
with contextlib.suppress(Exception):
|
|
stats = router.compile_stats()
|
|
|
|
def _numeric(value, default=0):
|
|
return value if isinstance(value, (int, float)) else default
|
|
|
|
destination_hash_raw = getattr(
|
|
router.propagation_destination,
|
|
"hexhash",
|
|
None,
|
|
)
|
|
if destination_hash_raw is None:
|
|
destination_hash_raw = getattr(
|
|
router.propagation_destination,
|
|
"hash",
|
|
None,
|
|
)
|
|
if isinstance(destination_hash_raw, bytes):
|
|
destination_hash = destination_hash_raw.hex()
|
|
elif isinstance(destination_hash_raw, str):
|
|
destination_hash = destination_hash_raw
|
|
else:
|
|
destination_hash = None
|
|
|
|
message_store = stats.get("messagestore", {}) if isinstance(stats, dict) else {}
|
|
clients = stats.get("clients", {}) if isinstance(stats, dict) else {}
|
|
peers = stats.get("peers", {}) if isinstance(stats, dict) else {}
|
|
uptime = _numeric(stats.get("uptime", 0)) if isinstance(stats, dict) else 0
|
|
peer_rx_bytes = 0
|
|
peer_tx_bytes = 0
|
|
if isinstance(peers, dict):
|
|
for peer_stats in peers.values():
|
|
if not isinstance(peer_stats, dict):
|
|
continue
|
|
peer_rx_bytes += int(_numeric(peer_stats.get("rx_bytes", 0)))
|
|
peer_tx_bytes += int(_numeric(peer_stats.get("tx_bytes", 0)))
|
|
unpeered_rx_bytes = (
|
|
int(_numeric(stats.get("unpeered_propagation_rx_bytes", 0)))
|
|
if isinstance(stats, dict)
|
|
else 0
|
|
)
|
|
delivery_limit = (
|
|
_numeric(stats.get("delivery_limit", 0))
|
|
if isinstance(stats, dict)
|
|
else _numeric(getattr(router, "delivery_per_transfer_limit", 0))
|
|
)
|
|
propagation_limit = (
|
|
_numeric(stats.get("propagation_limit", 0))
|
|
if isinstance(stats, dict)
|
|
else _numeric(getattr(router, "propagation_per_transfer_limit", 0))
|
|
)
|
|
sync_limit = (
|
|
_numeric(stats.get("sync_limit", 0))
|
|
if isinstance(stats, dict)
|
|
else _numeric(getattr(router, "propagation_per_sync_limit", 0))
|
|
)
|
|
result = {
|
|
"is_running": is_running,
|
|
"identity_hash": ctx.identity.hash.hex(),
|
|
"destination_hash": destination_hash,
|
|
"uptime_seconds": int(uptime) if uptime else 0,
|
|
"messagestore_count": message_store.get("count", 0),
|
|
"messagestore_bytes": message_store.get("bytes", 0),
|
|
"messagestore_limit_bytes": message_store.get("limit"),
|
|
"client_messages_received": clients.get(
|
|
"client_propagation_messages_received",
|
|
0,
|
|
),
|
|
"client_messages_served": clients.get(
|
|
"client_propagation_messages_served",
|
|
0,
|
|
),
|
|
"rx_bytes": peer_rx_bytes + unpeered_rx_bytes,
|
|
"tx_bytes": peer_tx_bytes,
|
|
"unpeered_rx_bytes": unpeered_rx_bytes,
|
|
"static_peers": stats.get("static_peers", 0)
|
|
if isinstance(stats, dict)
|
|
else 0,
|
|
"discovered_peers": (
|
|
stats.get("discovered_peers", 0) if isinstance(stats, dict) else 0
|
|
),
|
|
"total_peers": stats.get("total_peers", 0)
|
|
if isinstance(stats, dict)
|
|
else 0,
|
|
"max_peers": stats.get("max_peers") if isinstance(stats, dict) else None,
|
|
"delivery_limit_bytes": int(delivery_limit * 1000),
|
|
"propagation_limit_bytes": int(propagation_limit * 1000),
|
|
"sync_limit_bytes": int(sync_limit * 1000),
|
|
"target_stamp_cost": _numeric(
|
|
(
|
|
stats.get("target_stamp_cost", 0)
|
|
if isinstance(stats, dict)
|
|
else getattr(router, "propagation_stamp_cost", 0)
|
|
),
|
|
),
|
|
"inbound_delivery_count": 0,
|
|
"inbound_deliveries": [],
|
|
"max_inbound_syncs": int(
|
|
getattr(router, "propagation_max_inbound_syncs", 0) or 0,
|
|
),
|
|
"sequential_validation": bool(
|
|
getattr(router, "propagation_sequential_validation", True),
|
|
),
|
|
"static_peers_bypass_sequential": not bool(
|
|
getattr(router, "propagation_static_peer_sequential", False),
|
|
),
|
|
}
|
|
inbound_deliveries = list_inbound_deliveries(router)
|
|
result["inbound_deliveries"] = inbound_deliveries
|
|
result["inbound_delivery_count"] = len(inbound_deliveries)
|
|
return result
|
|
|
|
def _get_reticulum_section(self):
|
|
try:
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
reticulum_config = self.reticulum.config["reticulum"]
|
|
else:
|
|
return {}
|
|
except Exception:
|
|
reticulum_config = None
|
|
|
|
if not isinstance(reticulum_config, dict):
|
|
reticulum_config = {}
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.config["reticulum"] = reticulum_config
|
|
|
|
return reticulum_config
|
|
|
|
@staticmethod
|
|
def _parse_rns_config_bool(value, default=False):
|
|
"""Parse Reticulum config Yes/No / bool-ish values."""
|
|
if value is None:
|
|
return bool(default)
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value != 0
|
|
text = str(value).strip().lower()
|
|
if text in ("yes", "true", "1", "on"):
|
|
return True
|
|
if text in ("no", "false", "0", "off", ""):
|
|
return False
|
|
return bool(default)
|
|
|
|
@staticmethod
|
|
def _format_rns_config_bool(value):
|
|
return "Yes" if bool(value) else "No"
|
|
|
|
@staticmethod
|
|
def _parse_rns_hash_list(value):
|
|
"""Parse a RNS config identity-hash list into lowercase hex strings."""
|
|
items = []
|
|
if value is None:
|
|
return items
|
|
if isinstance(value, (list, tuple)):
|
|
raw_items = value
|
|
else:
|
|
raw_items = str(value).replace(",", " ").split()
|
|
try:
|
|
expected = int(RNS.Reticulum.TRUNCATED_HASHLENGTH) // 8 * 2
|
|
except Exception:
|
|
expected = 32
|
|
if expected <= 0:
|
|
expected = 32
|
|
for item in raw_items:
|
|
text = str(item).strip().lower()
|
|
if not text:
|
|
continue
|
|
if len(text) != expected:
|
|
raise ValueError(
|
|
f"Identity hash {text} must be {expected} hexadecimal characters",
|
|
)
|
|
try:
|
|
bytes.fromhex(text)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid identity hash: {text}") from exc
|
|
if text not in items:
|
|
items.append(text)
|
|
return items
|
|
|
|
def _get_reticulum_rpc_key_hex(self):
|
|
"""Return the live or configured RPC key as lowercase hex, or None."""
|
|
reticulum = getattr(self, "reticulum", None)
|
|
if reticulum is not None:
|
|
key = getattr(reticulum, "rpc_key", None)
|
|
if isinstance(key, (bytes, bytearray)) and key:
|
|
try:
|
|
return RNS.hexrep(key, delimit=False)
|
|
except Exception:
|
|
return bytes(key).hex()
|
|
if isinstance(key, str) and key.strip():
|
|
return key.strip().lower()
|
|
section = self._get_reticulum_section()
|
|
raw = section.get("rpc_key")
|
|
if isinstance(raw, str) and raw.strip():
|
|
return raw.strip().lower()
|
|
return None
|
|
|
|
def _build_reticulum_instance_settings(self):
|
|
"""Sideband-parity shared-instance / RPC / hop-obfuscation settings."""
|
|
section = self._get_reticulum_section()
|
|
reticulum = getattr(self, "reticulum", None)
|
|
share_default = True
|
|
if reticulum is not None and hasattr(reticulum, "share_instance"):
|
|
share_default = bool(reticulum.share_instance)
|
|
share_instance = self._parse_rns_config_bool(
|
|
section.get("share_instance"),
|
|
default=share_default,
|
|
)
|
|
if "local_hops_delta" in section:
|
|
local_hops_delta = self._parse_rns_config_bool(
|
|
section.get("local_hops_delta"),
|
|
default=False,
|
|
)
|
|
else:
|
|
local_hops_delta = False
|
|
if reticulum is not None and hasattr(RNS.Reticulum, "local_hops_delta"):
|
|
try:
|
|
local_hops_delta = bool(RNS.Reticulum.local_hops_delta())
|
|
except Exception:
|
|
pass
|
|
|
|
shared_type_raw = section.get("shared_instance_type")
|
|
shared_instance_type = None
|
|
if isinstance(shared_type_raw, str) and shared_type_raw.strip():
|
|
shared_instance_type = shared_type_raw.strip().lower()
|
|
elif reticulum is not None:
|
|
live_type = getattr(reticulum, "shared_instance_type", None)
|
|
if isinstance(live_type, str) and live_type.strip():
|
|
shared_instance_type = live_type.strip().lower()
|
|
|
|
instance_name = section.get("instance_name")
|
|
if not isinstance(instance_name, str) or not instance_name.strip():
|
|
instance_name = "default"
|
|
else:
|
|
instance_name = instance_name.strip()
|
|
|
|
is_connected = bool(
|
|
reticulum is not None
|
|
and getattr(reticulum, "is_connected_to_shared_instance", False),
|
|
)
|
|
rpc_key = self._get_reticulum_rpc_key_hex()
|
|
rpc_snippet = None
|
|
if rpc_key:
|
|
type_line = shared_instance_type or "tcp"
|
|
rpc_snippet = f"shared_instance_type = {type_line}\nrpc_key = {rpc_key}"
|
|
|
|
return {
|
|
"share_instance": share_instance,
|
|
"local_hops_delta": local_hops_delta,
|
|
"shared_instance_type": shared_instance_type,
|
|
"instance_name": instance_name,
|
|
"rpc_key": rpc_key,
|
|
"rpc_config_snippet": rpc_snippet,
|
|
"is_connected_to_shared_instance": is_connected,
|
|
"enable_transport": self._parse_rns_config_bool(
|
|
section.get("enable_transport"),
|
|
default=bool(
|
|
reticulum is not None
|
|
and getattr(reticulum, "transport_enabled", lambda: False)(),
|
|
),
|
|
),
|
|
"respond_to_probes": self._parse_rns_config_bool(
|
|
section.get("respond_to_probes"),
|
|
default=False,
|
|
),
|
|
"enable_remote_management": self._parse_rns_config_bool(
|
|
section.get("enable_remote_management"),
|
|
default=False,
|
|
),
|
|
"remote_management_allowed": self._safe_remote_management_allowed(
|
|
section.get("remote_management_allowed"),
|
|
),
|
|
}
|
|
|
|
def _safe_remote_management_allowed(self, value):
|
|
try:
|
|
return self._parse_rns_hash_list(value)
|
|
except ValueError:
|
|
return []
|
|
|
|
def _get_interfaces_section(self):
|
|
try:
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
interfaces = self.reticulum.config["interfaces"]
|
|
else:
|
|
return {}
|
|
except Exception:
|
|
interfaces = None
|
|
|
|
if not isinstance(interfaces, dict):
|
|
interfaces = {}
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.config["interfaces"] = interfaces
|
|
|
|
return interfaces
|
|
|
|
@staticmethod
|
|
def _copy_interface_section(details):
|
|
try:
|
|
return copy.deepcopy(dict(details))
|
|
except Exception:
|
|
try:
|
|
return copy.deepcopy(details)
|
|
except Exception:
|
|
return {}
|
|
|
|
def _disk_interface_sections(self) -> dict:
|
|
"""Parse [[interface]] sections from the on-disk Reticulum config file.
|
|
|
|
The Interfaces page reads reticulum.config in memory. The raw config
|
|
editor reads the file. Those two can diverge after a raw save, a
|
|
failed write, or an external edit, which hides a configured interface
|
|
from the tiles.
|
|
"""
|
|
try:
|
|
path = self._reticulum_config_file_path()
|
|
except Exception:
|
|
path = None
|
|
if not path or not os.path.isfile(path):
|
|
return {}
|
|
try:
|
|
from RNS.vendor.configobj import ConfigObj
|
|
|
|
cfg = ConfigObj(path)
|
|
except Exception:
|
|
return {}
|
|
interfaces = cfg.get("interfaces")
|
|
if not isinstance(interfaces, dict):
|
|
return {}
|
|
copied = {}
|
|
for name, details in interfaces.items():
|
|
if not isinstance(details, dict):
|
|
continue
|
|
copied[str(name)] = self._copy_interface_section(details)
|
|
return copied
|
|
|
|
def _sync_interfaces_from_disk(self, *, replace: bool = False) -> None:
|
|
"""Align the live interfaces map with the on-disk config file.
|
|
|
|
replace False adds disk-only sections so tiles and delete/enable can
|
|
see them. replace True makes the live map match the file (raw editor
|
|
save or restore).
|
|
"""
|
|
disk = self._disk_interface_sections()
|
|
mem = self._get_interfaces_section()
|
|
if replace:
|
|
for name in list(mem.keys()):
|
|
if name not in disk:
|
|
del mem[name]
|
|
for name, details in disk.items():
|
|
if replace or name not in mem:
|
|
mem[name] = details
|
|
|
|
def _get_interfaces_snapshot(self):
|
|
snapshot = {}
|
|
interfaces = self._get_interfaces_section()
|
|
for name, interface in interfaces.items():
|
|
try:
|
|
snapshot[name] = copy.deepcopy(dict(interface))
|
|
except Exception:
|
|
try:
|
|
snapshot[name] = copy.deepcopy(interface)
|
|
except Exception:
|
|
snapshot[name] = {}
|
|
return snapshot
|
|
|
|
def _sanitize_interfaces_section_names(self) -> None:
|
|
"""Rewrite interface section keys so ConfigObj can reload the config file."""
|
|
interfaces = self._get_interfaces_section()
|
|
if not isinstance(interfaces, dict) or not interfaces:
|
|
return
|
|
renamed: dict = {}
|
|
changed = False
|
|
for name, details in list(interfaces.items()):
|
|
safe = InterfaceEditor.sanitize_interface_section_name(name)
|
|
if not safe:
|
|
safe = "Interface"
|
|
if safe != name:
|
|
changed = True
|
|
# Avoid collisions after sanitizing two distinct names to the same key.
|
|
final = safe
|
|
suffix = 2
|
|
while final in renamed:
|
|
final = f"{safe} ({suffix})"
|
|
suffix += 1
|
|
if final != name:
|
|
changed = True
|
|
renamed[final] = details
|
|
if changed:
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.config["interfaces"] = renamed
|
|
|
|
def _verify_reticulum_config_reloadable(self) -> None:
|
|
"""Ensure the on-disk config still parses after write (ConfigObj quirk)."""
|
|
from RNS.vendor.configobj import ConfigObj
|
|
|
|
path = None
|
|
try:
|
|
path = self._api_reticulum_config_path()
|
|
except Exception:
|
|
path = None
|
|
if not path:
|
|
path = getattr(getattr(self, "reticulum", None), "configpath", None)
|
|
if not path or not os.path.isfile(path):
|
|
return
|
|
ConfigObj(path)
|
|
|
|
def _write_reticulum_config(self, *, rollback_interfaces=None):
|
|
try:
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self._sanitize_interfaces_section_names()
|
|
try:
|
|
i2p_support.repair_interfaces_dict(
|
|
self._get_interfaces_section(),
|
|
self._get_reticulum_section(),
|
|
)
|
|
except Exception as i2p_exc:
|
|
print(f"I2P config repair before write failed: {i2p_exc}")
|
|
self.reticulum.config.write()
|
|
self._verify_reticulum_config_reloadable()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
print(f"Failed to write Reticulum config: {e}")
|
|
if (
|
|
rollback_interfaces is not None
|
|
and hasattr(self, "reticulum")
|
|
and self.reticulum
|
|
):
|
|
try:
|
|
self.reticulum.config["interfaces"] = rollback_interfaces
|
|
except Exception as restore_exc:
|
|
print(
|
|
"Failed to restore Reticulum interfaces after write error: "
|
|
f"{restore_exc}",
|
|
)
|
|
return False
|
|
|
|
def _detect_failed_autointerfaces(self):
|
|
"""Return AutoInterface section names enabled in config but not running."""
|
|
enabled_names = []
|
|
try:
|
|
interfaces = self._get_interfaces_section()
|
|
except Exception:
|
|
return []
|
|
|
|
if not isinstance(interfaces, dict) or not interfaces:
|
|
return []
|
|
|
|
for name, section in interfaces.items():
|
|
if not isinstance(section, dict):
|
|
continue
|
|
if str(section.get("type", "")).strip() != "AutoInterface":
|
|
continue
|
|
enabled_raw = (
|
|
str(
|
|
section.get("enabled") or section.get("interface_enabled") or "",
|
|
)
|
|
.strip()
|
|
.lower()
|
|
)
|
|
if enabled_raw in ("yes", "true", "1"):
|
|
enabled_names.append(name)
|
|
|
|
if not enabled_names:
|
|
return []
|
|
|
|
try:
|
|
live = getattr(RNS.Transport, "interfaces", None) or []
|
|
for iface in live:
|
|
if iface.__class__.__name__ == "AutoInterface":
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
return enabled_names
|
|
|
|
def build_user_guidance_messages(self):
|
|
guidance = []
|
|
|
|
interfaces = self._get_interfaces_section()
|
|
if len(interfaces) == 0:
|
|
guidance.append(
|
|
{
|
|
"id": "no_interfaces",
|
|
"title": "No Reticulum interfaces configured",
|
|
"description": "Add at least one Reticulum interface so MeshChat can talk to your radio or transport.",
|
|
"action_route": "/interfaces/add",
|
|
"action_label": "Add Interface",
|
|
"severity": "warning",
|
|
},
|
|
)
|
|
|
|
failed_autointerfaces = self._detect_failed_autointerfaces()
|
|
if failed_autointerfaces:
|
|
failed_label = ", ".join(failed_autointerfaces)
|
|
guidance.append(
|
|
{
|
|
"id": "autointerface_bind_failed",
|
|
"title": "AutoInterface failed to start",
|
|
"description": (
|
|
f"AutoInterface '{failed_label}' is enabled in your "
|
|
"Reticulum config but did not come up at runtime. "
|
|
"The most common cause is a UDP port collision with "
|
|
"another local Reticulum application (for example "
|
|
"Sideband running on the same device on Android). "
|
|
"Open the interface and set a unique group_id, or "
|
|
"pick free discovery_port and data_port values, then "
|
|
"restart Reticulum."
|
|
),
|
|
"action_route": "/interfaces",
|
|
"action_label": "Open Interfaces",
|
|
"severity": "warning",
|
|
},
|
|
)
|
|
|
|
if (
|
|
hasattr(self, "reticulum")
|
|
and self.reticulum
|
|
and not self.reticulum.transport_enabled()
|
|
):
|
|
guidance.append(
|
|
{
|
|
"id": "transport_disabled",
|
|
"title": "Transport mode is disabled",
|
|
"description": "Enable transport to allow MeshChat to relay traffic over your configured interfaces.",
|
|
"action_route": "/settings",
|
|
"action_label": "Open Settings",
|
|
"severity": "info",
|
|
},
|
|
)
|
|
|
|
if not self.config.auto_announce_enabled.get():
|
|
guidance.append(
|
|
{
|
|
"id": "announce_disabled",
|
|
"title": "Auto announcements are turned off",
|
|
"description": "Automatic announces make it easier for other peers to discover you. Enable them if you want to stay visible.",
|
|
"action_route": "/settings",
|
|
"action_label": "Manage Announce Settings",
|
|
"severity": "info",
|
|
},
|
|
)
|
|
|
|
return guidance
|
|
|
|
# returns the latest message for the provided destination hash
|
|
def get_conversation_latest_message(self, destination_hash: str):
|
|
local_hash = self.identity.hash.hex()
|
|
messages = self.message_handler.get_conversation_messages(
|
|
local_hash,
|
|
destination_hash,
|
|
limit=1,
|
|
)
|
|
return messages[0] if messages else None
|
|
|
|
# returns true if the conversation with the provided destination hash has any attachments
|
|
def conversation_has_attachments(self, destination_hash: str):
|
|
local_hash = self.identity.hash.hex()
|
|
messages = self.message_handler.get_conversation_messages(
|
|
local_hash,
|
|
destination_hash,
|
|
)
|
|
for message in messages:
|
|
if message_fields_have_attachments(message["fields"]):
|
|
return True
|
|
return False
|
|
|
|
def search_destination_hashes_by_message(self, search_term: str):
|
|
if search_term is None or search_term.strip() == "":
|
|
return set()
|
|
|
|
local_hash = self.local_lxmf_destination.hexhash
|
|
search_term = search_term.strip()
|
|
matches = set()
|
|
|
|
query_results = self.message_handler.search_messages(local_hash, search_term)
|
|
|
|
for message in query_results:
|
|
if message["source_hash"] == local_hash:
|
|
matches.add(message["destination_hash"])
|
|
else:
|
|
matches.add(message["source_hash"])
|
|
|
|
# also check custom display names
|
|
custom_names = (
|
|
self.database.announces.get_announces()
|
|
) # Or more specific if needed
|
|
for announce in custom_names:
|
|
custom_name = self.database.announces.get_custom_display_name(
|
|
announce["destination_hash"],
|
|
)
|
|
if custom_name and search_term.lower() in custom_name.lower():
|
|
matches.add(announce["destination_hash"])
|
|
|
|
return matches
|
|
|
|
def on_new_voicemail_received(
|
|
self,
|
|
remote_hash,
|
|
remote_name,
|
|
duration,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
# Add system notification
|
|
self.database.misc.add_notification(
|
|
notification_type="telephone_voicemail",
|
|
remote_hash=remote_hash,
|
|
title="New Voicemail",
|
|
content=f"New voicemail from {remote_name or remote_hash} ({duration}s)",
|
|
)
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "new_voicemail",
|
|
"remote_identity_hash": remote_hash,
|
|
"remote_identity_name": remote_name,
|
|
"duration": duration,
|
|
"timestamp": time.time(),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# handle receiving a new audio call
|
|
def on_incoming_telephone_call(self, caller_identity: RNS.Identity, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# Reject all calls if telephony is disabled
|
|
if not ctx.config.telephone_enabled.get():
|
|
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
|
if telephone:
|
|
threading.Timer(
|
|
0.5,
|
|
lambda t=telephone: t.hangup(),
|
|
).start()
|
|
return
|
|
|
|
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
|
|
# Outgoing dial owns the line. Reject the inbound caller so they are
|
|
# not left ringing while we ignore the callback locally.
|
|
print(
|
|
"on_incoming_telephone_call: Rejecting as we are currently initiating an outgoing call.",
|
|
)
|
|
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
|
if telephone:
|
|
threading.Timer(
|
|
0.5,
|
|
lambda t=telephone: t.hangup(),
|
|
).start()
|
|
return
|
|
|
|
caller_hash = caller_identity.hash.hex()
|
|
|
|
# Check if caller is blocked
|
|
if self.is_destination_blocked(caller_hash, context=ctx):
|
|
print(f"Rejecting incoming call from blocked source: {caller_hash}")
|
|
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
|
if telephone:
|
|
# Use a small delay to avoid deadlocking with LXST call_handler_lock
|
|
threading.Timer(
|
|
0.5,
|
|
lambda t=telephone: t.hangup(),
|
|
).start()
|
|
return
|
|
|
|
# Check for Do Not Disturb
|
|
if ctx.config.do_not_disturb_enabled.get():
|
|
print(f"Rejecting incoming call due to Do Not Disturb: {caller_hash}")
|
|
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
|
if telephone:
|
|
# Use a small delay to ensure LXST state is ready for hangup
|
|
threading.Timer(
|
|
0.5,
|
|
lambda t=telephone: t.hangup(),
|
|
).start()
|
|
return
|
|
|
|
# Check if only allowing calls from contacts, or blocking all from strangers
|
|
if (
|
|
ctx.config.telephone_allow_calls_from_contacts_only.get()
|
|
or ctx.config.block_all_from_strangers.get()
|
|
):
|
|
if not self._is_contact(caller_hash, context=ctx):
|
|
print(f"Rejecting incoming call from non-contact: {caller_hash}")
|
|
telephone = getattr(ctx.telephone_manager, "telephone", None)
|
|
if telephone:
|
|
threading.Timer(
|
|
0.5,
|
|
lambda t=telephone: t.hangup(),
|
|
).start()
|
|
return
|
|
|
|
# Trigger voicemail handling
|
|
ctx.voicemail_manager.handle_incoming_call(caller_identity)
|
|
|
|
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
|
|
ch = caller_identity.hash.hex()
|
|
caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh"
|
|
is_contact = self._is_contact(ch, context=ctx)
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "telephone_ringing",
|
|
"remote_identity_hash": ch,
|
|
"remote_identity_name": caller_name,
|
|
"is_contact": is_contact,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_telephone_call_established(
|
|
self,
|
|
caller_identity: RNS.Identity,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
print(f"on_telephone_call_established: {caller_identity.hash.hex()}")
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "telephone_call_established",
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_telephone_call_ended(self, caller_identity: RNS.Identity, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
# Stop voicemail recording if active
|
|
ctx.voicemail_manager.stop_recording()
|
|
|
|
print(
|
|
f"on_telephone_call_ended: {caller_identity.hash.hex() if caller_identity else 'Unknown'}",
|
|
)
|
|
try:
|
|
self.web_audio_bridge.on_call_ended()
|
|
except Exception as e:
|
|
logging.exception(f"Error in web_audio_bridge.on_call_ended: {e}")
|
|
|
|
# Record call history
|
|
if caller_identity:
|
|
remote_identity_hash = caller_identity.hash.hex()
|
|
remote_identity_name = self.get_name_for_identity_hash(remote_identity_hash)
|
|
|
|
is_incoming = ctx.telephone_manager.call_is_incoming
|
|
status_code = ctx.telephone_manager.call_status_at_end
|
|
|
|
status_map = {
|
|
0: "Busy",
|
|
1: "Rejected",
|
|
2: "Calling",
|
|
3: "Available",
|
|
4: "Ringing",
|
|
5: "Connecting",
|
|
6: "Completed",
|
|
}
|
|
status_text = status_map.get(status_code, f"Status {status_code}")
|
|
|
|
duration = 0
|
|
if ctx.telephone_manager.call_start_time:
|
|
duration = int(time.time() - ctx.telephone_manager.call_start_time)
|
|
|
|
ctx.database.telephone.add_call_history(
|
|
remote_identity_hash=remote_identity_hash,
|
|
remote_identity_name=remote_identity_name,
|
|
is_incoming=is_incoming,
|
|
status=status_text,
|
|
duration_seconds=duration,
|
|
timestamp=time.time(),
|
|
)
|
|
|
|
# Trigger missed call notification if it was an incoming call that ended without being established
|
|
if is_incoming and not ctx.telephone_manager.call_was_established:
|
|
if not self.is_destination_blocked(remote_identity_hash, context=ctx):
|
|
ctx.database.misc.add_notification(
|
|
notification_type="telephone_missed_call",
|
|
remote_hash=remote_identity_hash,
|
|
title="Missed Call",
|
|
content=f"You missed a call from {remote_identity_name or remote_identity_hash}",
|
|
)
|
|
|
|
if not self.incoming_call_is_policy_filtered(
|
|
remote_identity_hash,
|
|
context=ctx,
|
|
):
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "telephone_missed_call",
|
|
"remote_identity_hash": remote_identity_hash,
|
|
"remote_identity_name": remote_identity_name,
|
|
"timestamp": time.time(),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "telephone_call_ended",
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_telephone_initiation_status(self, status, target_hash, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
target_name = None
|
|
if target_hash:
|
|
try:
|
|
contact = ctx.database.contacts.get_contact_by_identity_hash(
|
|
target_hash,
|
|
)
|
|
if contact:
|
|
target_name = contact.name
|
|
except Exception:
|
|
pass
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "telephone_initiation_status",
|
|
"status": status,
|
|
"target_hash": target_hash,
|
|
"target_name": target_name,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rrc_change(self, hub, context=None):
|
|
"""Broadcast an RRC hub state change to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rrc.change",
|
|
"hub_hash": hub.hub_hash.hex() if hub is not None else None,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def _rrc_mention_remote_hash(self, hub_hash_hex, room):
|
|
from meshchatx.src.backend.rrc import protocol as rrc_protocol
|
|
|
|
return f"{hub_hash_hex}:{rrc_protocol.normalize_room(room)}"
|
|
|
|
def _maybe_add_rrc_mention_notification(self, hub, msg, context=None):
|
|
if hub is None or not getattr(msg, "mention", False):
|
|
return
|
|
if msg.kind not in ("msg", "action"):
|
|
return
|
|
if not msg.room:
|
|
return
|
|
ctx = context or self.current_context
|
|
if ctx is None:
|
|
return
|
|
from meshchatx.src.backend.rrc import protocol as rrc_protocol
|
|
|
|
room = rrc_protocol.normalize_room(msg.room)
|
|
hub_hash = hub.hub_hash.hex()
|
|
remote_hash = self._rrc_mention_remote_hash(hub_hash, room)
|
|
hub_label = (
|
|
hub.get_display_name()
|
|
if hasattr(hub, "get_display_name")
|
|
else (hub.name or hub_hash[:12])
|
|
)
|
|
nick = msg.nick if isinstance(msg.nick, str) and msg.nick else None
|
|
if not nick and isinstance(msg.src, (bytes, bytearray)):
|
|
nick = msg.src.hex()[:12]
|
|
nick = nick or "Someone"
|
|
preview = (msg.text or "").strip()
|
|
if len(preview) > 180:
|
|
preview = preview[:177] + "..."
|
|
title = f"#{room} · {hub_label}"
|
|
content = f"{nick}: {preview}" if preview else f"{nick} mentioned you"
|
|
ctx.database.misc.dismiss_unviewed_notifications(
|
|
notification_type="rrc_mention",
|
|
remote_hash=remote_hash,
|
|
)
|
|
ctx.database.misc.add_notification(
|
|
"rrc_mention",
|
|
remote_hash,
|
|
title,
|
|
content,
|
|
)
|
|
|
|
def _mark_rrc_mention_notifications_viewed(self, hub_hash_hex, room, context=None):
|
|
ctx = context or self.current_context
|
|
if ctx is None or not room:
|
|
return
|
|
with contextlib.suppress(ValueError):
|
|
ctx.database.misc.dismiss_unviewed_notifications(
|
|
notification_type="rrc_mention",
|
|
remote_hash=self._rrc_mention_remote_hash(hub_hash_hex, room),
|
|
)
|
|
|
|
def on_rrc_message(self, hub, msg, context=None):
|
|
"""Broadcast a new RRC message to connected clients."""
|
|
with contextlib.suppress(Exception):
|
|
self._maybe_add_rrc_mention_notification(hub, msg, context=context)
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rrc.message",
|
|
"hub_hash": hub.hub_hash.hex() if hub is not None else None,
|
|
"room": msg.room,
|
|
"message": msg.to_dict(),
|
|
"mention": bool(getattr(msg, "mention", False)),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rrc_server_change(self, hub, context=None):
|
|
"""Broadcast a hosted RRC hub state change to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rrc.server.change",
|
|
"hub_id": hub.hub_id if hub is not None else None,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rnsh_change(self, session, context=None):
|
|
"""Broadcast an RNSh session state change to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rnsh.session.change",
|
|
"session_id": session.session_id
|
|
if session is not None
|
|
else None,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rnsh_output(self, session, chunk, context=None):
|
|
"""Broadcast RNSh output chunks to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rnsh.output",
|
|
"session_id": session.session_id
|
|
if session is not None
|
|
else None,
|
|
"chunk": chunk,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rnx_change(self, session, context=None):
|
|
"""Broadcast an RNX session state change to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rnx.session.change",
|
|
"session_id": session.session_id
|
|
if session is not None
|
|
else None,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_rnx_output(self, session, chunk, context=None):
|
|
"""Broadcast RNX output chunks to connected clients."""
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "rnx.output",
|
|
"session_id": session.session_id
|
|
if session is not None
|
|
else None,
|
|
"chunk": chunk,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# web server has shutdown, likely ctrl+c, but if we don't do the following, the script never exits
|
|
async def shutdown(self, app):
|
|
for identity_hash in list(self.contexts.keys()):
|
|
ctx = self.contexts.get(identity_hash)
|
|
if ctx is None:
|
|
continue
|
|
bh = getattr(ctx, "bot_handler", None)
|
|
if bh is not None:
|
|
with contextlib.suppress(Exception):
|
|
bh.stop_all()
|
|
|
|
if hasattr(self, "page_node_manager"):
|
|
self.page_node_manager.teardown()
|
|
|
|
for identity_hash in list(self.contexts.keys()):
|
|
ctx = self.contexts.get(identity_hash)
|
|
if ctx is None:
|
|
continue
|
|
try:
|
|
ctx.teardown()
|
|
except Exception:
|
|
pass
|
|
self.contexts.clear()
|
|
self.current_context = None
|
|
|
|
if hasattr(self, "_health_monitor") and self._health_monitor is not None:
|
|
with contextlib.suppress(Exception):
|
|
self._health_monitor.stop()
|
|
|
|
if self._mem_diag is not None:
|
|
with contextlib.suppress(Exception):
|
|
self._mem_diag.stop()
|
|
|
|
# force close websocket clients (copy: close() may touch the client list)
|
|
for websocket_client in list(self.websocket_clients):
|
|
try:
|
|
await websocket_client.close(code=WSCloseCode.GOING_AWAY)
|
|
except Exception:
|
|
pass
|
|
|
|
# stop reticulum
|
|
try:
|
|
RNS.Transport.detach_interfaces()
|
|
except Exception:
|
|
pass
|
|
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
try:
|
|
self.reticulum.exit_handler()
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
RNS.exit()
|
|
except Exception:
|
|
pass
|
|
|
|
def exit_app(self, code=0):
|
|
sys.exit(code)
|
|
|
|
def _require_identity_context_ready(self):
|
|
"""Return an HTTP 503 response when identity/DB is not ready yet."""
|
|
if not self.current_context or not getattr(
|
|
self.current_context,
|
|
"running",
|
|
False,
|
|
):
|
|
return web.json_response(
|
|
{
|
|
"message": "Identity context is still starting. Retry shortly.",
|
|
"stage": self._startup_stage,
|
|
"network_ready": bool(self._network_ready),
|
|
},
|
|
status=503,
|
|
)
|
|
if self.database is None or self.message_handler is None:
|
|
return web.json_response(
|
|
{
|
|
"message": "Database is still starting. Retry shortly.",
|
|
"stage": self._startup_stage,
|
|
"network_ready": bool(self._network_ready),
|
|
},
|
|
status=503,
|
|
)
|
|
if self.local_lxmf_destination is None:
|
|
return web.json_response(
|
|
{
|
|
"message": "Local LXMF destination is still starting. Retry shortly.",
|
|
"stage": self._startup_stage,
|
|
"network_ready": bool(self._network_ready),
|
|
},
|
|
status=503,
|
|
)
|
|
return None
|
|
|
|
def _require_rns_tool_handler(self, handler, tool_name: str):
|
|
"""Return 503 when an RNS tool handler is unavailable (e.g. mid-reload)."""
|
|
if handler is None:
|
|
return web.json_response(
|
|
{
|
|
"message": f"{tool_name} is unavailable while the RNS stack is reloading.",
|
|
"stage": self._startup_stage,
|
|
"network_ready": bool(self._network_ready),
|
|
},
|
|
status=503,
|
|
)
|
|
reticulum = getattr(handler, "reticulum", None)
|
|
if reticulum is None and not hasattr(self, "reticulum"):
|
|
return web.json_response(
|
|
{
|
|
"message": f"{tool_name} is unavailable while the RNS stack is reloading.",
|
|
"stage": self._startup_stage,
|
|
"network_ready": bool(self._network_ready),
|
|
},
|
|
status=503,
|
|
)
|
|
return None
|
|
|
|
def _require_outbound_http(self, feature: str) -> None:
|
|
if self.config:
|
|
ensure_outbound_http_allowed(self.config, feature=feature)
|
|
|
|
def _landlock_status_dict(self) -> dict:
|
|
return {
|
|
"landlock_kernel_supported": landlock_kernel_supported(),
|
|
"landlock_requested": landlock_requested(),
|
|
"landlock_auto_enabled": landlock_auto_enabled(),
|
|
"landlock_disabled_by_env": landlock_disabled_by_env(),
|
|
"landlock_active": self.landlock_active,
|
|
"appcontainer_supported": appcontainer_supported(),
|
|
"appcontainer_requested": appcontainer_requested(),
|
|
"appcontainer_auto_enabled": appcontainer_auto_enabled(),
|
|
"appcontainer_disabled_by_env": appcontainer_disabled_by_env(),
|
|
"appcontainer_active": self.appcontainer_active,
|
|
"fs_sandbox_active": bool(self.landlock_active or self.appcontainer_active),
|
|
"seccomp_kernel_supported": seccomp_kernel_supported(),
|
|
"seccomp_requested": seccomp_requested(),
|
|
"seccomp_auto_enabled": seccomp_auto_enabled(),
|
|
"seccomp_disabled_by_env": seccomp_disabled_by_env(),
|
|
"seccomp_active": self.seccomp_active,
|
|
}
|
|
|
|
@property
|
|
def fs_sandbox_active(self) -> bool:
|
|
return bool(self.landlock_active or self.appcontainer_active)
|
|
|
|
def get_routes(self):
|
|
routes = web.RouteTableDef()
|
|
self._define_routes(routes)
|
|
return routes
|
|
|
|
def _define_routes(self, routes):
|
|
from meshchatx.src.backend.http.register import register_all_routes
|
|
|
|
(
|
|
auth_middleware,
|
|
mime_type_middleware,
|
|
security_middleware,
|
|
csrf_middleware,
|
|
ip_allowlist_middleware,
|
|
demo_mode_middleware,
|
|
) = register_all_routes(routes, self)
|
|
|
|
return (
|
|
auth_middleware,
|
|
mime_type_middleware,
|
|
security_middleware,
|
|
csrf_middleware,
|
|
ip_allowlist_middleware,
|
|
demo_mode_middleware,
|
|
)
|
|
|
|
def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage:
|
|
try:
|
|
secret_key_bytes = base64.urlsafe_b64decode(self.session_secret_key + "===")
|
|
if len(secret_key_bytes) < 32:
|
|
secret_key_bytes = secret_key_bytes.ljust(32, b"\0")
|
|
elif len(secret_key_bytes) > 32:
|
|
secret_key_bytes = secret_key_bytes[:32]
|
|
except Exception:
|
|
secret_key_bytes = hashlib.sha256(
|
|
self.session_secret_key.encode("utf-8"),
|
|
).digest()
|
|
return EncryptedCookieStorage(
|
|
secret_key_bytes,
|
|
secure=use_https,
|
|
httponly=True,
|
|
samesite="Lax",
|
|
)
|
|
|
|
def _enforce_login_access(self, request, path: str):
|
|
if not self.database:
|
|
return None
|
|
ip = _request_client_ip(request, get_trusted_proxy_cidrs(self.storage_dir))
|
|
ua = request.headers.get("User-Agent", "") or ""
|
|
ua_h = user_agent_hash(ua)
|
|
id_hash = self.identity.hash.hex()
|
|
dao = self.database.access_attempts
|
|
trusted = dao.is_trusted(id_hash, ip, ua_h)
|
|
now = time.time()
|
|
if trusted:
|
|
if (
|
|
dao.count_login_attempts_ip_ua(
|
|
ip,
|
|
ua_h,
|
|
path,
|
|
now - WINDOW_RATE_TRUSTED_S,
|
|
)
|
|
>= MAX_TRUSTED_LOGIN_PER_WINDOW
|
|
):
|
|
dao.insert(
|
|
id_hash,
|
|
ip,
|
|
ua,
|
|
path,
|
|
request.method,
|
|
"rate_limited",
|
|
"trusted_window",
|
|
)
|
|
return web.json_response(
|
|
{"error": "Too many requests. Try again later."},
|
|
status=429,
|
|
)
|
|
else:
|
|
if (
|
|
dao.count_login_attempts_ip(ip, path, now - WINDOW_RATE_UNTRUSTED_S)
|
|
>= MAX_UNTRUSTED_LOGIN_PER_WINDOW
|
|
):
|
|
dao.insert(
|
|
id_hash,
|
|
ip,
|
|
ua,
|
|
path,
|
|
request.method,
|
|
"rate_limited",
|
|
"ip_window",
|
|
)
|
|
return web.json_response(
|
|
{"error": "Too many requests. Try again later."},
|
|
status=429,
|
|
)
|
|
if (
|
|
dao.count_lockout_failures(id_hash, ip, now - WINDOW_LOCKOUT_S)
|
|
>= MAX_FAILED_BEFORE_LOCKOUT
|
|
):
|
|
dao.insert(
|
|
id_hash,
|
|
ip,
|
|
ua,
|
|
path,
|
|
request.method,
|
|
"lockout",
|
|
"failures",
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"error": "Too many failed login attempts from this address. Try again later.",
|
|
},
|
|
status=429,
|
|
)
|
|
return None
|
|
|
|
def run(self, host, port, launch_browser: bool, enable_https: bool = True):
|
|
# create route table
|
|
routes = web.RouteTableDef()
|
|
(
|
|
auth_middleware,
|
|
mime_type_middleware,
|
|
security_middleware,
|
|
csrf_middleware,
|
|
ip_allowlist_middleware,
|
|
demo_mode_middleware,
|
|
) = self._define_routes(routes)
|
|
|
|
ssl_context = None
|
|
use_https = enable_https
|
|
self.listen_host = host
|
|
self.listen_port = port
|
|
self.use_https = use_https
|
|
if enable_https:
|
|
custom_ssl = bool(self.ssl_cert_path and self.ssl_key_path)
|
|
if custom_ssl:
|
|
cert_path = os.path.abspath(self.ssl_cert_path)
|
|
key_path = os.path.abspath(self.ssl_key_path)
|
|
else:
|
|
cert_dir = os.path.join(self.storage_path, "ssl")
|
|
cert_path = os.path.join(cert_dir, "cert.pem")
|
|
key_path = os.path.join(cert_dir, "key.pem")
|
|
|
|
try:
|
|
if custom_ssl:
|
|
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
|
|
msg = f"Custom SSL files not found (cert={cert_path!r}, key={key_path!r})"
|
|
raise FileNotFoundError(msg)
|
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ssl_context.load_cert_chain(cert_path, key_path)
|
|
print(
|
|
f"HTTPS enabled with custom certificate at {cert_path}",
|
|
flush=True,
|
|
)
|
|
else:
|
|
generate_ssl_certificate(cert_path, key_path)
|
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ssl_context.load_cert_chain(cert_path, key_path)
|
|
print(f"HTTPS enabled with certificate at {cert_path}", flush=True)
|
|
except Exception as e:
|
|
if custom_ssl:
|
|
print(f"Failed to load custom SSL certificate: {e}")
|
|
else:
|
|
print(f"Failed to generate SSL certificate: {e}")
|
|
print("Falling back to HTTP")
|
|
use_https = False
|
|
|
|
# session secret for encrypted cookies (generate once and store in shared storage)
|
|
session_secret_path = os.path.join(self.storage_dir, "session_secret")
|
|
self.session_secret_key = None
|
|
|
|
if os.path.exists(session_secret_path):
|
|
try:
|
|
with open(session_secret_path) as f:
|
|
self.session_secret_key = f.read().strip()
|
|
except Exception as e:
|
|
print(f"Failed to read session secret from {session_secret_path}: {e}")
|
|
|
|
if not self.session_secret_key:
|
|
# try to migrate from current identity config if available
|
|
if self.config is not None:
|
|
self.session_secret_key = self.config.auth_session_secret.get()
|
|
if not self.session_secret_key:
|
|
self.session_secret_key = secrets.token_urlsafe(32)
|
|
|
|
try:
|
|
with open(session_secret_path, "w") as f:
|
|
f.write(self.session_secret_key)
|
|
except Exception as e:
|
|
print(f"Failed to write session secret to {session_secret_path}: {e}")
|
|
|
|
# ensure it's also in the current config for consistency when identity is ready
|
|
if self.config is not None:
|
|
self.config.auth_session_secret.set(self.session_secret_key)
|
|
|
|
# called when web app has started
|
|
async def on_startup(app):
|
|
# remember main event loop
|
|
AsyncUtils.set_main_loop(asyncio.get_event_loop())
|
|
|
|
if not self._network_ready:
|
|
self.start_network_setup_in_background()
|
|
|
|
# auto launch web browser
|
|
if launch_browser:
|
|
try:
|
|
protocol = "https" if use_https else "http"
|
|
webbrowser.open(f"{protocol}://127.0.0.1:{port}")
|
|
except Exception:
|
|
print("failed to launch web browser")
|
|
|
|
# start memory diagnostics periodic snapshot task
|
|
if self._mem_diag and self._mem_diag.enabled:
|
|
asyncio.create_task(self._memory_diag_snapshot_loop())
|
|
|
|
# create and run web app
|
|
app = web.Application(
|
|
client_max_size=1024 * 1024 * 256,
|
|
) # allow large message exports with embedded attachments
|
|
|
|
# setup session storage
|
|
# aiohttp_session.setup must be called before other middlewares that use sessions
|
|
setup_session(
|
|
app,
|
|
self._encrypted_cookie_storage(use_https),
|
|
)
|
|
|
|
# add other middlewares
|
|
app.middlewares.extend(
|
|
[
|
|
auth_middleware,
|
|
mime_type_middleware,
|
|
security_middleware,
|
|
csrf_middleware,
|
|
ip_allowlist_middleware,
|
|
demo_mode_middleware,
|
|
],
|
|
)
|
|
|
|
app.add_routes(routes)
|
|
|
|
async def robots_txt_handler(_request):
|
|
return web.Response(
|
|
text="User-agent: *\nDisallow: /\n",
|
|
content_type="text/plain; charset=utf-8",
|
|
)
|
|
|
|
app.router.add_get("/robots.txt", robots_txt_handler)
|
|
|
|
# serve anything else from public folder
|
|
# we use add_static here as it's more robust for serving directories
|
|
public_dir = self.get_public_path()
|
|
|
|
# Serve Reticulum docs from user-uploaded storage with a fallback to the
|
|
# bundled offline copy shipped under <public>/reticulum-docs-bundled/current.
|
|
# No remote network fallback exists, so users supply replacements via upload.
|
|
async def reticulum_docs_handler(request):
|
|
dm = self.docs_manager
|
|
if dm is None:
|
|
return web.json_response(
|
|
{"error": "Documentation unavailable"},
|
|
status=503,
|
|
)
|
|
path = request.match_info.get("filename", "manual/index.html")
|
|
if not path:
|
|
path = "manual/index.html"
|
|
if path.endswith("/"):
|
|
path += "index.html"
|
|
|
|
resolved = dm.find_docs_file(path)
|
|
if resolved is None:
|
|
return web.json_response(
|
|
{"error": "Documentation not found"},
|
|
status=404,
|
|
)
|
|
return web.FileResponse(resolved)
|
|
|
|
app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
|
|
|
|
dm = self.docs_manager
|
|
if (
|
|
dm
|
|
and dm.meshchatx_docs_dir
|
|
and os.path.exists(dm.meshchatx_docs_dir)
|
|
and not dm.meshchatx_docs_dir.startswith(public_dir)
|
|
):
|
|
app.router.add_static(
|
|
"/meshchatx-docs/",
|
|
dm.meshchatx_docs_dir,
|
|
name="meshchatx_docs_storage",
|
|
follow_symlinks=True,
|
|
)
|
|
|
|
if os.path.exists(public_dir):
|
|
app.router.add_static("/", public_dir, name="static", follow_symlinks=True)
|
|
else:
|
|
print(f"Warning: Static files directory not found at {public_dir}")
|
|
|
|
app.on_shutdown.append(
|
|
self.shutdown,
|
|
) # need to force close websockets and stop reticulum now
|
|
app.on_startup.append(on_startup)
|
|
|
|
protocol = "https" if use_https else "http"
|
|
print(f"Starting web server on {protocol}://{host}:{port}", flush=True)
|
|
|
|
# Start memory diagnostics if enabled
|
|
if self._memory_diag_enabled:
|
|
from meshchatx.src.backend.diagnostics import MemoryDiagnostics
|
|
|
|
self._mem_diag = MemoryDiagnostics()
|
|
self._mem_diag.start()
|
|
print(
|
|
"[mem_diag] Memory diagnostics enabled — "
|
|
"see /api/v1/diagnostics/memory for reports",
|
|
)
|
|
|
|
if use_https and ssl_context:
|
|
web.run_app(app, host=host, port=port, ssl_context=ssl_context)
|
|
else:
|
|
web.run_app(app, host=host, port=port)
|
|
|
|
# auto backup loop
|
|
async def auto_backup_loop(self, session_id, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# wait 5 minutes before first backup
|
|
await asyncio.sleep(300)
|
|
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
try:
|
|
if not self.emergency:
|
|
print(
|
|
f"Performing scheduled auto-backup for {ctx.identity_hash}...",
|
|
)
|
|
max_count = ctx.config.backup_max_count.get()
|
|
ctx.database.backup_database(ctx.storage_path, max_count=max_count)
|
|
except Exception as e:
|
|
print(f"Auto-backup failed: {e}")
|
|
|
|
# Sleep for 12 hours
|
|
await asyncio.sleep(12 * 3600)
|
|
|
|
async def local_message_retention_loop(self, session_id, context=None):
|
|
from meshchatx.src.backend import local_message_retention as lmr
|
|
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
await asyncio.sleep(lmr.LOCAL_RETENTION_STARTUP_GRACE_SECONDS)
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
try:
|
|
if not ctx.config.local_message_auto_delete_enabled.get():
|
|
await asyncio.sleep(300)
|
|
continue
|
|
now = time.time()
|
|
if not interval_action_due(
|
|
True,
|
|
ctx.config.local_message_auto_delete_last_run_at.get(),
|
|
lmr.RETENTION_CHECK_INTERVAL_SECONDS,
|
|
now,
|
|
):
|
|
await asyncio.sleep(60)
|
|
continue
|
|
v = ctx.config.local_message_auto_delete_value.get() or 30
|
|
u = ctx.config.local_message_auto_delete_unit.get() or "days"
|
|
if ctx.message_router is not None:
|
|
|
|
def _cancel(h):
|
|
try:
|
|
ctx.message_router.cancel_outbound(h)
|
|
except Exception:
|
|
pass
|
|
|
|
else:
|
|
_cancel = None
|
|
lmr.apply_local_message_retention(
|
|
ctx.database.messages,
|
|
_cancel,
|
|
value=int(v),
|
|
unit=str(u),
|
|
now=now,
|
|
)
|
|
ctx.config.local_message_auto_delete_last_run_at.set(int(now))
|
|
except Exception as e:
|
|
print(f"local_message_retention_loop failed: {e}")
|
|
await asyncio.sleep(60)
|
|
|
|
async def telemetry_tracking_loop(self, session_id, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
try:
|
|
# Only run if telemetry is enabled globally
|
|
if not ctx.config.telemetry_enabled.get():
|
|
await asyncio.sleep(60)
|
|
continue
|
|
|
|
# Get all tracked peers
|
|
tracked_peers = ctx.database.telemetry.get_tracked_peers()
|
|
now = time.time()
|
|
|
|
for peer in tracked_peers:
|
|
dest_hash = peer["destination_hash"]
|
|
interval = peer.get("interval_seconds", 60)
|
|
last_req = peer.get("last_request_at")
|
|
|
|
if last_req is None or now - last_req >= interval:
|
|
print(f"Sending telemetry request to tracked peer: {dest_hash}")
|
|
# Send telemetry request
|
|
await self.send_message(
|
|
destination_hash=dest_hash,
|
|
content="",
|
|
commands=[{SidebandCommands.TELEMETRY_REQUEST: 0}],
|
|
delivery_method="opportunistic",
|
|
no_display=False,
|
|
context=ctx,
|
|
)
|
|
# Update last request time
|
|
ctx.database.telemetry.update_last_request_at(dest_hash, now)
|
|
|
|
except Exception as e:
|
|
print(f"Telemetry tracking loop error: {e}")
|
|
|
|
# Check every 10 seconds
|
|
await asyncio.sleep(10)
|
|
|
|
# handle announcing
|
|
async def announce(self, context=None):
|
|
if self.demo_mode:
|
|
return
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# update last announced at timestamp
|
|
ctx.config.last_announced_at.set(int(time.time()))
|
|
|
|
# send announce for lxmf (ensuring name is updated before announcing)
|
|
ctx.local_lxmf_destination.display_name = ctx.config.display_name.get()
|
|
ctx.message_router.announce(destination_hash=ctx.local_lxmf_destination.hash)
|
|
|
|
# send announce for local propagation node (if enabled)
|
|
if ctx.config.lxmf_local_propagation_node_enabled.get():
|
|
ctx.message_router.announce_propagation_node()
|
|
|
|
# send announce for telephone (can be disabled to reduce unsolicited
|
|
# incoming telephony link attempts from public lxst.telephony announces)
|
|
if ctx.config.telephone_announce_enabled.get():
|
|
ctx.telephone_manager.announce(display_name=ctx.config.display_name.get())
|
|
|
|
# tell websocket clients we just announced
|
|
await self.send_announced_to_websocket_clients(context=ctx)
|
|
|
|
# handle syncing propagation nodes
|
|
async def sync_propagation_nodes(self, context=None, force=False):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return False
|
|
|
|
router = ctx.message_router
|
|
if not router:
|
|
return False
|
|
|
|
outbound_node = router.get_outbound_propagation_node()
|
|
if outbound_node is None:
|
|
return False
|
|
|
|
state = router.propagation_transfer_state
|
|
if propagation_sync_idle_like(state):
|
|
if state == router.PR_COMPLETE:
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
router.propagation_transfer_progress = 0.0
|
|
elif not force:
|
|
return False
|
|
else:
|
|
self.stop_propagation_node_sync(context=ctx)
|
|
settle_deadline = time.monotonic() + 5.0
|
|
while time.monotonic() < settle_deadline:
|
|
if router.propagation_transfer_state == router.PR_IDLE:
|
|
break
|
|
await asyncio.sleep(0.2)
|
|
else:
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_IDLE
|
|
|
|
self._begin_propagation_sync_metrics(context=ctx)
|
|
|
|
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time()))
|
|
local_propagation_destination = getattr(
|
|
router,
|
|
"propagation_destination",
|
|
None,
|
|
)
|
|
local_propagation_hash = getattr(local_propagation_destination, "hash", None)
|
|
if (
|
|
isinstance(outbound_node, (bytes, bytearray))
|
|
and isinstance(local_propagation_hash, (bytes, bytearray))
|
|
and bytes(outbound_node) == bytes(local_propagation_hash)
|
|
):
|
|
# Local node selected as preferred: no transport path lookup is needed.
|
|
# Mark sync as complete immediately to avoid getting stuck in PR_PATH_REQUESTED.
|
|
with contextlib.suppress(Exception):
|
|
router.propagation_transfer_state = router.PR_COMPLETE
|
|
router.propagation_transfer_progress = 1.0
|
|
router.propagation_transfer_last_result = 0
|
|
await self.send_config_to_websocket_clients(context=ctx)
|
|
return True
|
|
|
|
# Kick off the LXMF request on a worker thread. Identity.recall and link
|
|
# setup can block on multiprocessing pipes. Running inline would stall the
|
|
# HTTP handler and race with cancel_propagation_node_requests (EOFError).
|
|
asyncio.create_task(self._request_propagation_node_messages(context=ctx))
|
|
|
|
await self.send_config_to_websocket_clients(context=ctx)
|
|
return True
|
|
|
|
# helper to parse boolean from possible string or bool
|
|
@staticmethod
|
|
def _parse_bool(value):
|
|
if value is None:
|
|
return False
|
|
if isinstance(value, str):
|
|
return value.lower() == "true"
|
|
return bool(value)
|
|
|
|
# Coerce untrusted config input to int. Returns None when the value
|
|
# cannot be converted so update_config can skip or fall back instead of
|
|
# raising on None/strings/lists received via the websocket API.
|
|
@staticmethod
|
|
def _coerce_int(value):
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
async def update_config(self, data):
|
|
# update display name in config
|
|
if "display_name" in data and data["display_name"] != "":
|
|
self.config.display_name.set(data["display_name"])
|
|
# Update identity metadata cache
|
|
self.update_identity_metadata_cache()
|
|
|
|
# update theme in config
|
|
if "theme" in data and data["theme"] != "":
|
|
self.config.theme.set(data["theme"])
|
|
|
|
# update language in config
|
|
if "language" in data and data["language"] != "":
|
|
self.config.language.set(data["language"])
|
|
|
|
# update auto announce interval
|
|
if "auto_announce_interval_seconds" in data:
|
|
# auto auto announce interval
|
|
auto_announce_interval_seconds = self._coerce_int(
|
|
data["auto_announce_interval_seconds"],
|
|
)
|
|
if auto_announce_interval_seconds is None:
|
|
auto_announce_interval_seconds = (
|
|
self.config.auto_announce_interval_seconds.get()
|
|
)
|
|
self.config.auto_announce_interval_seconds.set(
|
|
auto_announce_interval_seconds,
|
|
)
|
|
|
|
# enable or disable auto announce based on interval
|
|
if auto_announce_interval_seconds > 0:
|
|
self.config.auto_announce_enabled.set(True)
|
|
else:
|
|
self.config.auto_announce_enabled.set(False)
|
|
|
|
if "auto_resend_failed_messages_when_announce_received" in data:
|
|
value = self._parse_bool(
|
|
data["auto_resend_failed_messages_when_announce_received"],
|
|
)
|
|
self.config.auto_resend_failed_messages_when_announce_received.set(value)
|
|
|
|
if "allow_auto_resending_failed_messages_with_attachments" in data:
|
|
value = self._parse_bool(
|
|
data["allow_auto_resending_failed_messages_with_attachments"],
|
|
)
|
|
self.config.allow_auto_resending_failed_messages_with_attachments.set(value)
|
|
|
|
if "auto_send_failed_messages_to_propagation_node" in data:
|
|
value = self._parse_bool(
|
|
data["auto_send_failed_messages_to_propagation_node"],
|
|
)
|
|
self.config.auto_send_failed_messages_to_propagation_node.set(value)
|
|
|
|
if "lxmf_delivery_transfer_limit_in_bytes" in data:
|
|
value = self._coerce_int(data["lxmf_delivery_transfer_limit_in_bytes"])
|
|
if value is None:
|
|
value = self.config.lxmf_delivery_transfer_limit_in_bytes.get()
|
|
value = max(1000, min(value, 1000 * 1000 * 1000))
|
|
self.config.lxmf_delivery_transfer_limit_in_bytes.set(value)
|
|
self.message_router.delivery_per_transfer_limit = value / 1000
|
|
|
|
if "lxmf_propagation_transfer_limit_in_bytes" in data:
|
|
value = self._coerce_int(data["lxmf_propagation_transfer_limit_in_bytes"])
|
|
if value is None:
|
|
value = self.config.lxmf_propagation_transfer_limit_in_bytes.get()
|
|
value = max(1000, min(value, 1000 * 1000 * 100))
|
|
self.config.lxmf_propagation_transfer_limit_in_bytes.set(value)
|
|
self.message_router.propagation_per_transfer_limit = value / 1000
|
|
if self.config.lxmf_local_propagation_node_enabled.get():
|
|
self.message_router.announce_propagation_node()
|
|
|
|
if "lxmf_propagation_sync_limit_in_bytes" in data:
|
|
value = self._coerce_int(data["lxmf_propagation_sync_limit_in_bytes"])
|
|
if value is None:
|
|
value = self.config.lxmf_propagation_sync_limit_in_bytes.get()
|
|
value = max(1000, min(value, 1000 * 1000 * 500))
|
|
self.config.lxmf_propagation_sync_limit_in_bytes.set(value)
|
|
self.message_router.propagation_per_sync_limit = value / 1000
|
|
|
|
if "show_suggested_community_interfaces" in data:
|
|
value = self._parse_bool(data["show_suggested_community_interfaces"])
|
|
self.config.show_suggested_community_interfaces.set(value)
|
|
|
|
_announce_int_fields = [
|
|
("announce_max_stored_lxmf_delivery", 1, 1_000_000),
|
|
("announce_max_stored_nomadnetwork_node", 1, 1_000_000),
|
|
("announce_max_stored_lxmf_propagation", 1, 1_000_000),
|
|
("announce_max_stored_map_data", 1, 1_000_000),
|
|
("announce_fetch_limit_lxmf_delivery", 1, 100_000),
|
|
("announce_fetch_limit_nomadnetwork_node", 1, 100_000),
|
|
("announce_fetch_limit_lxmf_propagation", 1, 100_000),
|
|
("announce_fetch_limit_map_data", 1, 100_000),
|
|
("announce_search_max_fetch", 100, 10_000),
|
|
("discovered_interfaces_max_return", 1, 50_000),
|
|
]
|
|
for key, lo, hi in _announce_int_fields:
|
|
if key not in data:
|
|
continue
|
|
val = data[key]
|
|
if val is None or val == "":
|
|
getattr(self.config, key).set(None)
|
|
continue
|
|
try:
|
|
v = int(val)
|
|
v = max(lo, min(hi, v))
|
|
getattr(self.config, key).set(v)
|
|
except (TypeError, ValueError):
|
|
getattr(self.config, key).set(None)
|
|
|
|
if "lxmf_preferred_propagation_node_destination_hash" in data:
|
|
# update config value
|
|
value = data["lxmf_preferred_propagation_node_destination_hash"]
|
|
self.config.lxmf_preferred_propagation_node_destination_hash.set(value)
|
|
|
|
# update active propagation node
|
|
self.set_active_propagation_node(value)
|
|
|
|
if "lxmf_preferred_propagation_node_auto_select" in data:
|
|
value = self._parse_bool(
|
|
data["lxmf_preferred_propagation_node_auto_select"],
|
|
)
|
|
self.config.lxmf_preferred_propagation_node_auto_select.set(value)
|
|
|
|
# update inbound stamp cost (for direct delivery messages)
|
|
if "lxmf_inbound_stamp_cost" in data:
|
|
value = self._coerce_int(data["lxmf_inbound_stamp_cost"])
|
|
if value is None:
|
|
value = self.config.lxmf_inbound_stamp_cost.get()
|
|
# 0 disables inbound stamps, and otherwise clamp to 1-254 (LXMF/LXMRouter)
|
|
if value < 0:
|
|
value = 0
|
|
elif value >= 255:
|
|
value = 254
|
|
# If block strangers is active, store the desired value for later restore
|
|
# but keep the enforced max cost active
|
|
if self.config.block_all_from_strangers.get():
|
|
self.config.lxmf_inbound_stamp_cost_before_block.set(value)
|
|
else:
|
|
self.config.lxmf_inbound_stamp_cost.set(value)
|
|
# update the inbound stamp cost on the delivery destination
|
|
self.message_router.set_inbound_stamp_cost(
|
|
self.local_lxmf_destination.hash,
|
|
value,
|
|
)
|
|
if value > 0:
|
|
self.message_router.enforce_stamps()
|
|
elif hasattr(self.message_router, "ignore_stamps"):
|
|
self.message_router.ignore_stamps()
|
|
# re-announce to update the stamp cost in announces
|
|
self.local_lxmf_destination.display_name = (
|
|
self.config.display_name.get()
|
|
)
|
|
self.message_router.announce(
|
|
destination_hash=self.local_lxmf_destination.hash,
|
|
)
|
|
|
|
# update propagation node stamp cost (for messages propagated through your node)
|
|
if "lxmf_propagation_node_stamp_cost" in data:
|
|
value = self._coerce_int(data["lxmf_propagation_node_stamp_cost"])
|
|
if value is None:
|
|
value = self.config.lxmf_propagation_node_stamp_cost.get()
|
|
# validate stamp cost (must be at least 13, per LXMF minimum)
|
|
if value < 13:
|
|
value = 13
|
|
elif value >= 255:
|
|
value = 254
|
|
self.config.lxmf_propagation_node_stamp_cost.set(value)
|
|
# update the propagation stamp cost on the router
|
|
self.message_router.propagation_stamp_cost = value
|
|
# re-announce propagation node if enabled
|
|
if self.config.lxmf_local_propagation_node_enabled.get():
|
|
self.message_router.announce_propagation_node()
|
|
|
|
if "lxmf_propagation_sequential_validation" in data:
|
|
value = self._parse_bool(
|
|
data["lxmf_propagation_sequential_validation"],
|
|
)
|
|
self.config.lxmf_propagation_sequential_validation.set(value)
|
|
if self.message_router is not None:
|
|
self.message_router.propagation_sequential_validation = value
|
|
|
|
if "lxmf_propagation_static_peers_bypass_sequential" in data:
|
|
value = self._parse_bool(
|
|
data["lxmf_propagation_static_peers_bypass_sequential"],
|
|
)
|
|
self.config.lxmf_propagation_static_peers_bypass_sequential.set(value)
|
|
if self.message_router is not None:
|
|
self.message_router.propagation_static_peer_sequential = not value
|
|
|
|
if "lxmf_propagation_max_inbound_syncs" in data:
|
|
value = self._coerce_int(data["lxmf_propagation_max_inbound_syncs"])
|
|
if value is None:
|
|
value = self.config.lxmf_propagation_max_inbound_syncs.get()
|
|
if value < 1:
|
|
value = 1
|
|
elif value > 64:
|
|
value = 64
|
|
self.config.lxmf_propagation_max_inbound_syncs.set(value)
|
|
if self.message_router is not None:
|
|
self.message_router.propagation_max_inbound_syncs = value
|
|
|
|
# update auto sync interval
|
|
if "lxmf_preferred_propagation_node_auto_sync_interval_seconds" in data:
|
|
value = self._coerce_int(
|
|
data["lxmf_preferred_propagation_node_auto_sync_interval_seconds"],
|
|
)
|
|
if value is None:
|
|
value = self.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
|
|
self.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.set(
|
|
value,
|
|
)
|
|
|
|
if "lxmf_local_propagation_node_enabled" in data:
|
|
# update config value
|
|
value = self._parse_bool(data["lxmf_local_propagation_node_enabled"])
|
|
self.config.lxmf_local_propagation_node_enabled.set(value)
|
|
|
|
# enable or disable local propagation node
|
|
self.enable_local_propagation_node(value)
|
|
|
|
# update lxmf user icon name in config
|
|
if "lxmf_user_icon_name" in data:
|
|
self.config.lxmf_user_icon_name.set(data["lxmf_user_icon_name"])
|
|
self.database.misc.clear_last_sent_icon_hashes()
|
|
self.update_identity_metadata_cache()
|
|
|
|
# update lxmf user icon foreground colour in config
|
|
if "lxmf_user_icon_foreground_colour" in data:
|
|
self.config.lxmf_user_icon_foreground_colour.set(
|
|
data["lxmf_user_icon_foreground_colour"],
|
|
)
|
|
self.database.misc.clear_last_sent_icon_hashes()
|
|
self.update_identity_metadata_cache()
|
|
|
|
# update lxmf user icon background colour in config
|
|
if "lxmf_user_icon_background_colour" in data:
|
|
self.config.lxmf_user_icon_background_colour.set(
|
|
data["lxmf_user_icon_background_colour"],
|
|
)
|
|
self.database.misc.clear_last_sent_icon_hashes()
|
|
self.update_identity_metadata_cache()
|
|
|
|
# update archiver settings
|
|
if "page_archiver_enabled" in data:
|
|
self.config.page_archiver_enabled.set(
|
|
self._parse_bool(data["page_archiver_enabled"]),
|
|
)
|
|
|
|
if "page_archiver_max_versions" in data:
|
|
value = self._coerce_int(data["page_archiver_max_versions"])
|
|
if value is not None:
|
|
self.config.page_archiver_max_versions.set(value)
|
|
|
|
if "archives_max_storage_gb" in data:
|
|
value = self._coerce_int(data["archives_max_storage_gb"])
|
|
if value is not None:
|
|
self.config.archives_max_storage_gb.set(value)
|
|
|
|
if "backup_max_count" in data:
|
|
try:
|
|
value = int(data["backup_max_count"])
|
|
except (TypeError, ValueError):
|
|
value = self.config.backup_max_count.default_value
|
|
value = max(1, min(value, 50))
|
|
self.config.backup_max_count.set(value)
|
|
|
|
# update crawler settings
|
|
if "crawler_enabled" in data:
|
|
self.config.crawler_enabled.set(self._parse_bool(data["crawler_enabled"]))
|
|
|
|
if "crawler_max_retries" in data:
|
|
try:
|
|
value = int(data["crawler_max_retries"])
|
|
except (TypeError, ValueError):
|
|
value = self.config.crawler_max_retries.default_value
|
|
self.config.crawler_max_retries.set(value)
|
|
|
|
if "crawler_retry_delay_seconds" in data:
|
|
try:
|
|
value = int(data["crawler_retry_delay_seconds"])
|
|
except (TypeError, ValueError):
|
|
value = self.config.crawler_retry_delay_seconds.default_value
|
|
self.config.crawler_retry_delay_seconds.set(value)
|
|
|
|
if "crawler_max_concurrent" in data:
|
|
try:
|
|
value = int(data["crawler_max_concurrent"])
|
|
except (TypeError, ValueError):
|
|
value = self.config.crawler_max_concurrent.default_value
|
|
self.config.crawler_max_concurrent.set(value)
|
|
|
|
if "auth_enabled" in data:
|
|
value = self._parse_bool(data["auth_enabled"])
|
|
self.config.auth_enabled.set(value)
|
|
|
|
# if disabling auth, also remove the password hash from config
|
|
if not value:
|
|
self.config.auth_password_hash.set(None)
|
|
|
|
if "privacy_mode_enabled" in data:
|
|
self.config.privacy_mode_enabled.set(
|
|
self._parse_bool(data["privacy_mode_enabled"]),
|
|
)
|
|
|
|
if "multi_session_warning_enabled" in data:
|
|
self.config.multi_session_warning_enabled.set(
|
|
self._parse_bool(data["multi_session_warning_enabled"]),
|
|
)
|
|
|
|
# update map settings
|
|
if "map_offline_enabled" in data:
|
|
self.config.map_offline_enabled.set(
|
|
self._parse_bool(data["map_offline_enabled"]),
|
|
)
|
|
|
|
if "map_default_lat" in data:
|
|
self.config.map_default_lat.set(str(data["map_default_lat"]))
|
|
|
|
if "map_default_lon" in data:
|
|
self.config.map_default_lon.set(str(data["map_default_lon"]))
|
|
|
|
if "map_default_zoom" in data:
|
|
try:
|
|
value = int(data["map_default_zoom"])
|
|
except (TypeError, ValueError):
|
|
value = None
|
|
if value is not None:
|
|
self.config.map_default_zoom.set(value)
|
|
|
|
if "map_mbtiles_dir" in data:
|
|
self.config.map_mbtiles_dir.set(data["map_mbtiles_dir"])
|
|
|
|
if "map_tile_cache_enabled" in data:
|
|
self.config.map_tile_cache_enabled.set(
|
|
self._parse_bool(data["map_tile_cache_enabled"]),
|
|
)
|
|
|
|
if "map_tile_server_url" in data:
|
|
self.config.map_tile_server_url.set(data["map_tile_server_url"])
|
|
|
|
if "map_nominatim_api_url" in data:
|
|
self.config.map_nominatim_api_url.set(data["map_nominatim_api_url"])
|
|
|
|
for overlay_key in CONFIG_CLAMPS:
|
|
if overlay_key in data:
|
|
try:
|
|
value = int(data[overlay_key])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
getattr(self.config, overlay_key).set(
|
|
clamp_overlay_config_value(overlay_key, value),
|
|
)
|
|
|
|
# update location settings
|
|
if "location_source" in data:
|
|
self.config.location_source.set(data["location_source"])
|
|
|
|
if "location_manual_lat" in data:
|
|
self.config.location_manual_lat.set(str(data["location_manual_lat"]))
|
|
|
|
if "location_manual_lon" in data:
|
|
self.config.location_manual_lon.set(str(data["location_manual_lon"]))
|
|
|
|
if "location_manual_alt" in data:
|
|
self.config.location_manual_alt.set(str(data["location_manual_alt"]))
|
|
|
|
if "telemetry_enabled" in data:
|
|
self.config.telemetry_enabled.set(
|
|
self._parse_bool(data["telemetry_enabled"]),
|
|
)
|
|
|
|
if "nomad_render_markdown_enabled" in data:
|
|
self.config.nomad_render_markdown_enabled.set(
|
|
self._parse_bool(data["nomad_render_markdown_enabled"]),
|
|
)
|
|
|
|
if "nomad_render_html_enabled" in data:
|
|
self.config.nomad_render_html_enabled.set(
|
|
self._parse_bool(data["nomad_render_html_enabled"]),
|
|
)
|
|
|
|
if "nomad_render_plaintext_enabled" in data:
|
|
self.config.nomad_render_plaintext_enabled.set(
|
|
self._parse_bool(data["nomad_render_plaintext_enabled"]),
|
|
)
|
|
|
|
if "nomad_micron_wasm_enabled" in data:
|
|
self.config.nomad_micron_wasm_enabled.set(
|
|
self._parse_bool(data["nomad_micron_wasm_enabled"]),
|
|
)
|
|
if not self.config.nomad_micron_wasm_enabled.get():
|
|
self.config.nomad_micron_default_engine.set("js")
|
|
|
|
if "nomad_micron_default_engine" in data:
|
|
if self.config.nomad_micron_wasm_enabled.get():
|
|
raw = str(data["nomad_micron_default_engine"] or "").strip().lower()
|
|
self.config.nomad_micron_default_engine.set(
|
|
"wasm" if raw == "wasm" else "js",
|
|
)
|
|
|
|
if "nomad_default_page_path" in data:
|
|
from meshchatx.src.backend.page_node import is_allowed_page_filename
|
|
|
|
raw = data["nomad_default_page_path"]
|
|
if raw is None or str(raw).strip() == "":
|
|
self.config.nomad_default_page_path.set("/page/index.mu")
|
|
else:
|
|
s = str(raw).strip()
|
|
if s.startswith("/page/"):
|
|
base = s[6:]
|
|
if (
|
|
base
|
|
and "/" not in base
|
|
and ".." not in base
|
|
and is_allowed_page_filename(base)
|
|
):
|
|
self.config.nomad_default_page_path.set(s)
|
|
|
|
if "local_message_auto_delete_enabled" in data:
|
|
self.config.local_message_auto_delete_enabled.set(
|
|
self._parse_bool(data["local_message_auto_delete_enabled"]),
|
|
)
|
|
if "message_blocklist_enabled" in data:
|
|
self.config.message_blocklist_enabled.set(
|
|
self._parse_bool(data["message_blocklist_enabled"]),
|
|
)
|
|
if (
|
|
"local_message_auto_delete_value" in data
|
|
or "local_message_auto_delete_unit" in data
|
|
):
|
|
from meshchatx.src.backend.local_message_retention import (
|
|
MAX_VALUE_DAYS,
|
|
MAX_VALUE_MONTHS,
|
|
normalize_unit,
|
|
)
|
|
|
|
u_str = str(
|
|
data.get("local_message_auto_delete_unit")
|
|
or self.config.local_message_auto_delete_unit.get()
|
|
or "days",
|
|
)
|
|
u_norm = normalize_unit(u_str)
|
|
if "local_message_auto_delete_unit" in data:
|
|
self.config.local_message_auto_delete_unit.set(u_norm)
|
|
v_raw = data.get(
|
|
"local_message_auto_delete_value",
|
|
self.config.local_message_auto_delete_value.get(),
|
|
)
|
|
try:
|
|
v = int(v_raw)
|
|
except (TypeError, ValueError):
|
|
v = 30
|
|
v = max(1, v)
|
|
v = min(v, MAX_VALUE_MONTHS if u_norm == "months" else MAX_VALUE_DAYS)
|
|
self.config.local_message_auto_delete_value.set(v)
|
|
|
|
if "block_attachments_from_strangers" in data:
|
|
self.config.block_attachments_from_strangers.set(
|
|
self._parse_bool(data["block_attachments_from_strangers"]),
|
|
)
|
|
|
|
if "block_all_from_strangers" in data:
|
|
new_value = self._parse_bool(data["block_all_from_strangers"])
|
|
old_value = self.config.block_all_from_strangers.get()
|
|
self.config.block_all_from_strangers.set(new_value)
|
|
if new_value and not old_value:
|
|
# Enabling block strangers: save current stamp cost and set to max
|
|
current_cost = self.config.lxmf_inbound_stamp_cost.get()
|
|
if not isinstance(current_cost, int):
|
|
current_cost = 0
|
|
if current_cost < 0:
|
|
current_cost = 0
|
|
elif current_cost > 254:
|
|
current_cost = 254
|
|
self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost)
|
|
self.config.lxmf_inbound_stamp_cost.set(254)
|
|
if self.message_router and self.local_lxmf_destination:
|
|
self.message_router.set_inbound_stamp_cost(
|
|
self.local_lxmf_destination.hash,
|
|
254,
|
|
)
|
|
self.message_router.enforce_stamps()
|
|
self.local_lxmf_destination.display_name = (
|
|
self.config.display_name.get()
|
|
)
|
|
self.message_router.announce(
|
|
destination_hash=self.local_lxmf_destination.hash,
|
|
)
|
|
elif not new_value and old_value:
|
|
# Disabling block strangers: restore previous stamp cost.
|
|
# Zero is a valid prior cost (stamps off). Only fall back to 8
|
|
# when no prior cost was saved (sentinel outside 0..254).
|
|
saved = self.config.lxmf_inbound_stamp_cost_before_block.get()
|
|
if isinstance(saved, int) and 0 <= saved <= 254:
|
|
restore_cost = saved
|
|
else:
|
|
restore_cost = 8
|
|
self.config.lxmf_inbound_stamp_cost.set(restore_cost)
|
|
self.config.lxmf_inbound_stamp_cost_before_block.set(-1)
|
|
if self.message_router and self.local_lxmf_destination:
|
|
self.message_router.set_inbound_stamp_cost(
|
|
self.local_lxmf_destination.hash,
|
|
restore_cost,
|
|
)
|
|
if restore_cost > 0:
|
|
self.message_router.enforce_stamps()
|
|
elif hasattr(self.message_router, "ignore_stamps"):
|
|
self.message_router.ignore_stamps()
|
|
self.local_lxmf_destination.display_name = (
|
|
self.config.display_name.get()
|
|
)
|
|
self.message_router.announce(
|
|
destination_hash=self.local_lxmf_destination.hash,
|
|
)
|
|
self.sync_telephone_call_policy()
|
|
|
|
# update flood protection settings
|
|
if "lxmf_flood_protection_enabled" in data:
|
|
self.config.lxmf_flood_protection_enabled.set(
|
|
self._parse_bool(data["lxmf_flood_protection_enabled"]),
|
|
)
|
|
if "lxmf_flood_threshold_per_minute" in data:
|
|
try:
|
|
value = int(data["lxmf_flood_threshold_per_minute"])
|
|
value = max(1, min(value, 1000))
|
|
self.config.lxmf_flood_threshold_per_minute.set(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if "lxmf_flood_max_stamp_cost" in data:
|
|
try:
|
|
value = int(data["lxmf_flood_max_stamp_cost"])
|
|
value = max(1, min(value, 254))
|
|
self.config.lxmf_flood_max_stamp_cost.set(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if "lxmf_flood_cooldown_seconds" in data:
|
|
try:
|
|
value = int(data["lxmf_flood_cooldown_seconds"])
|
|
value = max(30, min(value, 3600))
|
|
self.config.lxmf_flood_cooldown_seconds.set(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
if "show_unknown_contact_banner" in data:
|
|
self.config.show_unknown_contact_banner.set(
|
|
self._parse_bool(data["show_unknown_contact_banner"]),
|
|
)
|
|
|
|
if "warn_on_stranger_links" in data:
|
|
self.config.warn_on_stranger_links.set(
|
|
self._parse_bool(data["warn_on_stranger_links"]),
|
|
)
|
|
|
|
# update banishment settings
|
|
if "banished_effect_enabled" in data:
|
|
self.config.banished_effect_enabled.set(
|
|
self._parse_bool(data["banished_effect_enabled"]),
|
|
)
|
|
|
|
if "banished_text" in data:
|
|
self.config.banished_text.set(data["banished_text"])
|
|
|
|
if "banished_color" in data:
|
|
self.config.banished_color.set(data["banished_color"])
|
|
|
|
if "message_font_size" in data:
|
|
try:
|
|
value = int(data["message_font_size"])
|
|
except (TypeError, ValueError):
|
|
value = None
|
|
if value is not None:
|
|
self.config.message_font_size.set(value)
|
|
|
|
if "messages_sidebar_position" in data:
|
|
raw = data["messages_sidebar_position"]
|
|
if raw is not None:
|
|
s = str(raw).strip().lower()
|
|
if s in ("left", "right"):
|
|
self.config.messages_sidebar_position.set(s)
|
|
|
|
if "app_sidebar_layout" in data:
|
|
raw = data["app_sidebar_layout"]
|
|
if raw is not None:
|
|
s = str(raw).strip().lower()
|
|
if s in ("grouped", "classic"):
|
|
self.config.app_sidebar_layout.set(s)
|
|
|
|
if "message_icon_size" in data:
|
|
try:
|
|
value = int(data["message_icon_size"])
|
|
except (TypeError, ValueError):
|
|
value = None
|
|
if value is not None:
|
|
value = max(12, min(value, 96))
|
|
self.config.message_icon_size.set(value)
|
|
|
|
if "ui_transparency" in data:
|
|
try:
|
|
value = int(data["ui_transparency"])
|
|
except (TypeError, ValueError):
|
|
value = None
|
|
if value is not None:
|
|
self.config.ui_transparency.set(max(0, min(value, 100)))
|
|
|
|
if "ui_glass_enabled" in data:
|
|
self.config.ui_glass_enabled.set(
|
|
self._parse_bool(data["ui_glass_enabled"]),
|
|
)
|
|
|
|
if "messages_multi_pane_enabled" in data:
|
|
self.config.messages_multi_pane_enabled.set(
|
|
self._parse_bool(data["messages_multi_pane_enabled"]),
|
|
)
|
|
|
|
if "nomad_tabs_enabled" in data:
|
|
self.config.nomad_tabs_enabled.set(
|
|
self._parse_bool(data["nomad_tabs_enabled"]),
|
|
)
|
|
if "rrc_enabled" in data:
|
|
self.config.rrc_enabled.set(self._parse_bool(data["rrc_enabled"]))
|
|
if "rrc_unread_badges_enabled" in data:
|
|
self.config.rrc_unread_badges_enabled.set(
|
|
self._parse_bool(data["rrc_unread_badges_enabled"]),
|
|
)
|
|
|
|
if "message_outbound_bubble_color" in data:
|
|
self.config.message_outbound_bubble_color.set(
|
|
data["message_outbound_bubble_color"],
|
|
)
|
|
|
|
if "message_inbound_bubble_color" in data:
|
|
self.config.message_inbound_bubble_color.set(
|
|
data["message_inbound_bubble_color"],
|
|
)
|
|
|
|
if "message_failed_bubble_color" in data:
|
|
self.config.message_failed_bubble_color.set(
|
|
data["message_failed_bubble_color"],
|
|
)
|
|
|
|
if "message_waiting_bubble_color" in data:
|
|
self.config.message_waiting_bubble_color.set(
|
|
data["message_waiting_bubble_color"],
|
|
)
|
|
|
|
# update desktop settings
|
|
if "desktop_open_calls_in_separate_window" in data:
|
|
self.config.desktop_open_calls_in_separate_window.set(
|
|
self._parse_bool(data["desktop_open_calls_in_separate_window"]),
|
|
)
|
|
|
|
if "desktop_hardware_acceleration_enabled" in data:
|
|
enabled = self._parse_bool(data["desktop_hardware_acceleration_enabled"])
|
|
self.config.desktop_hardware_acceleration_enabled.set(enabled)
|
|
|
|
# write flag for electron to read on next launch
|
|
try:
|
|
disable_gpu_file = os.path.join(self.storage_dir, "disable-gpu")
|
|
if not enabled:
|
|
with open(disable_gpu_file, "w") as f:
|
|
f.write("true")
|
|
elif os.path.exists(disable_gpu_file):
|
|
os.remove(disable_gpu_file)
|
|
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)
|
|
|
|
for _k in (
|
|
"announce_store_lxmf_delivery",
|
|
"announce_store_lxst_telephony",
|
|
"announce_store_nomadnetwork_node",
|
|
"announce_store_lxmf_propagation",
|
|
"announce_store_map_data",
|
|
):
|
|
if _k in data:
|
|
getattr(self.config, _k).set(self._parse_bool(data[_k]))
|
|
|
|
# update csp extra sources
|
|
if "csp_extra_connect_src" in data:
|
|
self.config.csp_extra_connect_src.set(data["csp_extra_connect_src"])
|
|
if "csp_extra_img_src" in data:
|
|
self.config.csp_extra_img_src.set(data["csp_extra_img_src"])
|
|
if "csp_extra_frame_src" in data:
|
|
self.config.csp_extra_frame_src.set(data["csp_extra_frame_src"])
|
|
if "csp_extra_script_src" in data:
|
|
self.config.csp_extra_script_src.set(data["csp_extra_script_src"])
|
|
if "csp_extra_style_src" in data:
|
|
self.config.csp_extra_style_src.set(data["csp_extra_style_src"])
|
|
|
|
# update voicemail settings
|
|
if "voicemail_enabled" in data:
|
|
self.config.voicemail_enabled.set(
|
|
self._parse_bool(data["voicemail_enabled"]),
|
|
)
|
|
|
|
if "voicemail_greeting" in data:
|
|
self.config.voicemail_greeting.set(data["voicemail_greeting"])
|
|
|
|
if "voicemail_auto_answer_delay_seconds" in data:
|
|
value = self._coerce_int(data["voicemail_auto_answer_delay_seconds"])
|
|
if value is not None:
|
|
self.config.voicemail_auto_answer_delay_seconds.set(value)
|
|
|
|
if "voicemail_max_recording_seconds" in data:
|
|
value = self._coerce_int(data["voicemail_max_recording_seconds"])
|
|
if value is not None:
|
|
self.config.voicemail_max_recording_seconds.set(value)
|
|
|
|
if "voicemail_tts_speed" in data:
|
|
value = self._coerce_int(data["voicemail_tts_speed"])
|
|
if value is not None:
|
|
self.config.voicemail_tts_speed.set(value)
|
|
|
|
if "voicemail_tts_pitch" in data:
|
|
value = self._coerce_int(data["voicemail_tts_pitch"])
|
|
if value is not None:
|
|
self.config.voicemail_tts_pitch.set(value)
|
|
|
|
if "voicemail_tts_voice" in data:
|
|
self.config.voicemail_tts_voice.set(data["voicemail_tts_voice"])
|
|
|
|
if "voicemail_tts_word_gap" in data:
|
|
value = self._coerce_int(data["voicemail_tts_word_gap"])
|
|
if value is not None:
|
|
self.config.voicemail_tts_word_gap.set(value)
|
|
|
|
# update ringtone settings
|
|
if "custom_ringtone_enabled" in data:
|
|
self.config.custom_ringtone_enabled.set(
|
|
self._parse_bool(data["custom_ringtone_enabled"]),
|
|
)
|
|
if "ringtone_preferred_id" in data:
|
|
value = self._coerce_int(data["ringtone_preferred_id"])
|
|
if value is not None:
|
|
self.config.ringtone_preferred_id.set(value)
|
|
if "ringtone_volume" in data:
|
|
value = self._coerce_int(data["ringtone_volume"])
|
|
if value is not None:
|
|
self.config.ringtone_volume.set(value)
|
|
|
|
if "notification_sound_enabled" in data:
|
|
self.config.notification_sound_enabled.set(
|
|
self._parse_bool(data["notification_sound_enabled"]),
|
|
)
|
|
if "notification_sound_preferred_id" in data:
|
|
value = self._coerce_int(data["notification_sound_preferred_id"])
|
|
if value is not None:
|
|
self.config.notification_sound_preferred_id.set(value)
|
|
if "notification_sound_volume" in data:
|
|
value = self._coerce_int(data["notification_sound_volume"])
|
|
if value is not None:
|
|
self.config.notification_sound_volume.set(value)
|
|
|
|
if "do_not_disturb_enabled" in data:
|
|
self.config.do_not_disturb_enabled.set(
|
|
self._parse_bool(data["do_not_disturb_enabled"]),
|
|
)
|
|
self.sync_telephone_call_policy()
|
|
|
|
if "telephone_enabled" in data:
|
|
value = self._parse_bool(data["telephone_enabled"])
|
|
self.config.telephone_enabled.set(value)
|
|
if (
|
|
not value
|
|
and self.telephone_manager
|
|
and self.telephone_manager.telephone
|
|
):
|
|
self.telephone_manager.teardown()
|
|
elif value and self.telephone_manager:
|
|
self.telephone_manager.init_telephone()
|
|
self.sync_telephone_call_policy()
|
|
|
|
if "telephone_allow_calls_from_contacts_only" in data:
|
|
self.config.telephone_allow_calls_from_contacts_only.set(
|
|
self._parse_bool(data["telephone_allow_calls_from_contacts_only"]),
|
|
)
|
|
self.sync_telephone_call_policy()
|
|
|
|
if "telephone_announce_enabled" in data:
|
|
self.config.telephone_announce_enabled.set(
|
|
self._parse_bool(data["telephone_announce_enabled"]),
|
|
)
|
|
|
|
if "call_recording_enabled" in data:
|
|
value = self._parse_bool(data["call_recording_enabled"])
|
|
self.config.call_recording_enabled.set(value)
|
|
# if a call is active, start or stop recording immediately
|
|
if (
|
|
self.telephone_manager
|
|
and self.telephone_manager.telephone
|
|
and self.telephone_manager.telephone.active_call
|
|
):
|
|
if value:
|
|
self.telephone_manager.start_recording()
|
|
else:
|
|
self.telephone_manager.stop_recording()
|
|
|
|
if "telephone_tone_generator_enabled" in data:
|
|
self.config.telephone_tone_generator_enabled.set(
|
|
self._parse_bool(data["telephone_tone_generator_enabled"]),
|
|
)
|
|
|
|
if "telephone_tone_generator_volume" in data:
|
|
value = self._coerce_int(data["telephone_tone_generator_volume"])
|
|
if value is not None:
|
|
self.config.telephone_tone_generator_volume.set(value)
|
|
|
|
if "telephone_audio_profile_id" in data:
|
|
profile_id = self._coerce_int(data["telephone_audio_profile_id"])
|
|
if profile_id is None:
|
|
profile_id = self.config.telephone_audio_profile_id.get()
|
|
self.config.telephone_audio_profile_id.set(profile_id)
|
|
if self.telephone_manager:
|
|
await asyncio.to_thread(
|
|
self.telephone_manager.apply_preferred_profile,
|
|
profile_id,
|
|
)
|
|
|
|
if "telephone_call_mode_id" in data:
|
|
mode_id = self._coerce_int(data["telephone_call_mode_id"])
|
|
if mode_id is None:
|
|
mode_id = self.config.telephone_call_mode_id.get()
|
|
self.config.telephone_call_mode_id.set(mode_id)
|
|
if self.telephone_manager:
|
|
await asyncio.to_thread(
|
|
self.telephone_manager.apply_preferred_mode,
|
|
mode_id,
|
|
)
|
|
|
|
if "telephone_web_audio_enabled" in data:
|
|
self.config.telephone_web_audio_enabled.set(
|
|
self._parse_bool(data["telephone_web_audio_enabled"]),
|
|
)
|
|
|
|
if "telephone_web_audio_allow_fallback" in data:
|
|
self.config.telephone_web_audio_allow_fallback.set(
|
|
self._parse_bool(data["telephone_web_audio_allow_fallback"]),
|
|
)
|
|
|
|
if "translator_argos_enabled" in data:
|
|
v = self._parse_bool(data["translator_argos_enabled"])
|
|
self.config.translator_argos_enabled.set(v)
|
|
if hasattr(self, "translator_handler"):
|
|
self.translator_handler.translator_argos_enabled = v
|
|
|
|
if "translator_libretranslate_enabled" in data:
|
|
v = self._parse_bool(data["translator_libretranslate_enabled"])
|
|
self.config.translator_libretranslate_enabled.set(v)
|
|
if hasattr(self, "translator_handler"):
|
|
self.translator_handler.translator_libretranslate_enabled = v
|
|
|
|
if "translator_enabled" in data:
|
|
v = self._parse_bool(data["translator_enabled"])
|
|
self.config.translator_argos_enabled.set(v)
|
|
self.config.translator_libretranslate_enabled.set(v)
|
|
if hasattr(self, "translator_handler"):
|
|
th = self.translator_handler
|
|
th.translator_argos_enabled = v
|
|
th.translator_libretranslate_enabled = v
|
|
|
|
if "libretranslate_url" in data:
|
|
value = data["libretranslate_url"]
|
|
self.config.libretranslate_url.set(value)
|
|
if hasattr(self, "translator_handler"):
|
|
self.translator_handler.libretranslate_url = value
|
|
|
|
if "libretranslate_api_key" in data:
|
|
from meshchatx.src.backend.translator_handler import (
|
|
_normalize_optional_libretranslate_api_key,
|
|
)
|
|
|
|
raw = data["libretranslate_api_key"]
|
|
if raw is None or raw == "":
|
|
norm = None
|
|
else:
|
|
norm = _normalize_optional_libretranslate_api_key(str(raw))
|
|
|
|
self.config.libretranslate_api_key.set(norm)
|
|
if hasattr(self, "translator_handler"):
|
|
self.translator_handler.libretranslate_api_key = norm
|
|
|
|
# send config to websocket clients
|
|
await self.send_config_to_websocket_clients()
|
|
if "multi_session_warning_enabled" in data:
|
|
await self.send_active_sessions_to_websocket_clients()
|
|
|
|
# converts nomadnetwork page variables from a string to a map
|
|
# converts: "field1=123|field2=456"
|
|
# to the following map:
|
|
# - var_field1: 123
|
|
# - var_field2: 456
|
|
def archive_page(
|
|
self,
|
|
destination_hash: str,
|
|
page_path: str,
|
|
content: str,
|
|
is_manual: bool = False,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return None
|
|
return ctx.nomadnet_manager.archive_page(
|
|
destination_hash,
|
|
page_path,
|
|
content,
|
|
is_manual,
|
|
)
|
|
|
|
def get_archived_page_versions(self, destination_hash: str, page_path: str):
|
|
return self.nomadnet_manager.get_archived_page_versions(
|
|
destination_hash,
|
|
page_path,
|
|
)
|
|
|
|
def flush_all_archived_pages(self):
|
|
return self.nomadnet_manager.flush_all_archived_pages()
|
|
|
|
async def _websocket_session_authorized(self, client) -> bool:
|
|
if not self.auth_enabled:
|
|
return True
|
|
request = getattr(client, "_meshchatx_request", None)
|
|
if request is None:
|
|
request = getattr(client, "request", None)
|
|
if request is None:
|
|
return False
|
|
try:
|
|
session = await get_session(request)
|
|
except Exception:
|
|
return False
|
|
identity_hash = self.identity.hash.hex() if self.identity else None
|
|
return bool(
|
|
session.get("authenticated", False)
|
|
and identity_hash
|
|
and session.get("identity_hash") == identity_hash,
|
|
)
|
|
|
|
# handle data received from websocket client
|
|
async def on_websocket_data_received(self, client, data):
|
|
from meshchatx.src.backend.http.ws.dispatch import dispatch_websocket_data
|
|
|
|
await dispatch_websocket_data(self, client, data)
|
|
|
|
def _track_rns_link_task(self, client, task: asyncio.Task) -> None:
|
|
bucket = self._rns_link_tasks.get(client)
|
|
if bucket is None:
|
|
bucket = set()
|
|
self._rns_link_tasks[client] = bucket
|
|
bucket.add(task)
|
|
task.add_done_callback(lambda t, c=client: self._untrack_rns_link_task(c, t))
|
|
|
|
def _untrack_rns_link_task(self, client, task: asyncio.Task) -> None:
|
|
bucket = self._rns_link_tasks.get(client)
|
|
if not bucket:
|
|
return
|
|
bucket.discard(task)
|
|
if not bucket:
|
|
self._rns_link_tasks.pop(client, None)
|
|
|
|
def _cancel_rns_link_tasks_for_client(self, client) -> None:
|
|
bucket = self._rns_link_tasks.pop(client, None)
|
|
if bucket:
|
|
for task in bucket:
|
|
if not task.done():
|
|
task.cancel()
|
|
stale_keys = [k for k in self._rns_request_receipts if k[0] is client]
|
|
for key in stale_keys:
|
|
self._rns_request_receipts.pop(key, None)
|
|
|
|
@staticmethod
|
|
def _rns_link_parse_dest_aspect(data):
|
|
dest_hex = data.get("destination_hash")
|
|
aspect = data.get("aspect")
|
|
if not dest_hex or not aspect:
|
|
return None, None, "missing_destination_or_aspect"
|
|
dest = hex_identifier_to_bytes(dest_hex if isinstance(dest_hex, str) else None)
|
|
if dest is None or len(dest) != 16:
|
|
return None, None, "invalid_destination_hash"
|
|
return dest, aspect, None
|
|
|
|
@staticmethod
|
|
async def _rns_link_send(client, payload):
|
|
try:
|
|
await client.send_str(json.dumps(payload))
|
|
except Exception as e:
|
|
print(f"rns.link reply failed: {e}")
|
|
|
|
async def _handle_rns_link_open(self, client, data):
|
|
request_id = data.get("request_id")
|
|
dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
|
|
if err:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.open",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": err,
|
|
},
|
|
)
|
|
return
|
|
auto_identify = bool(data.get("auto_identify", False))
|
|
|
|
def on_phase(phase):
|
|
AsyncUtils.run_async(
|
|
self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.open",
|
|
"request_id": request_id,
|
|
"status": "phase",
|
|
"phase": phase,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
),
|
|
)
|
|
|
|
link, identified, failure_reason = await self.rns_link_manager.open_link(
|
|
dest_hash,
|
|
aspect,
|
|
auto_identify=auto_identify,
|
|
on_phase=on_phase,
|
|
)
|
|
if link is None:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.open",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": failure_reason or "unknown",
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
return
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.open",
|
|
"request_id": request_id,
|
|
"status": "success",
|
|
"identified": identified,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
|
|
async def _handle_rns_link_identify(self, client, data):
|
|
request_id = data.get("request_id")
|
|
dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
|
|
if err:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.identify",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": err,
|
|
},
|
|
)
|
|
return
|
|
ok, failure_reason = self.rns_link_manager.identify(dest_hash, aspect)
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.identify",
|
|
"request_id": request_id,
|
|
"status": "success" if ok else "failure",
|
|
"failure_reason": failure_reason,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
|
|
async def _handle_rns_link_request(self, client, data):
|
|
import base64
|
|
|
|
request_id = data.get("request_id")
|
|
dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
|
|
if err:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": err,
|
|
},
|
|
)
|
|
return
|
|
path = data.get("path")
|
|
if not path:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": "missing_path",
|
|
},
|
|
)
|
|
return
|
|
# data_b64 is msgpack-encoded request payload. Decode to a native value
|
|
# so RNS.Link.request embeds it in the wire envelope correctly.
|
|
data_b64 = data.get("data_b64")
|
|
try:
|
|
body_bytes = base64.b64decode(data_b64, validate=True) if data_b64 else None
|
|
except Exception:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": "invalid_data_b64",
|
|
},
|
|
)
|
|
return
|
|
if body_bytes is None or len(body_bytes) == 0:
|
|
link_request_data = None
|
|
else:
|
|
try:
|
|
from RNS.vendor import umsgpack
|
|
|
|
link_request_data = umsgpack.unpackb(body_bytes)
|
|
except Exception as e:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": f"data_msgpack_decode_failed: {e}",
|
|
},
|
|
)
|
|
return
|
|
timeout = data.get("timeout")
|
|
|
|
def on_phase(phase):
|
|
AsyncUtils.run_async(
|
|
self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "phase",
|
|
"phase": phase,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
),
|
|
)
|
|
|
|
link, _identified, failure_reason = await self.rns_link_manager.open_link(
|
|
dest_hash,
|
|
aspect,
|
|
auto_identify=False,
|
|
on_phase=on_phase,
|
|
)
|
|
if link is None:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": failure_reason or "unknown",
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
return
|
|
|
|
def on_response(request_receipt):
|
|
self._rns_request_receipts.pop((client, request_id), None)
|
|
raw = request_receipt.response
|
|
from RNS.vendor import umsgpack
|
|
|
|
try:
|
|
if hasattr(raw, "read") and not isinstance(raw, (bytes, bytearray)):
|
|
raw_to_pack = raw.read()
|
|
else:
|
|
raw_to_pack = raw
|
|
body_b64 = base64.b64encode(umsgpack.packb(raw_to_pack)).decode("ascii")
|
|
except Exception as e:
|
|
print(f"[rns.link.request] msgpack encode failed: {e}")
|
|
body_b64 = ""
|
|
AsyncUtils.run_async(
|
|
self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "success",
|
|
"body_b64": body_b64,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
),
|
|
)
|
|
|
|
def on_failed(_receipt=None):
|
|
self._rns_request_receipts.pop((client, request_id), None)
|
|
AsyncUtils.run_async(
|
|
self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": "request_failed",
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
),
|
|
)
|
|
|
|
def on_progress(receipt):
|
|
AsyncUtils.run_async(
|
|
self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "progress",
|
|
"progress": receipt.progress,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
),
|
|
)
|
|
|
|
try:
|
|
receipt = self.rns_link_manager.request(
|
|
dest_hash,
|
|
aspect,
|
|
path,
|
|
link_request_data,
|
|
response_callback=on_response,
|
|
failed_callback=on_failed,
|
|
progress_callback=on_progress,
|
|
timeout=timeout,
|
|
)
|
|
self._rns_request_receipts[(client, request_id)] = receipt
|
|
except Exception as e:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.request",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": f"request_dispatch_failed: {e}",
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
|
|
async def _handle_rns_link_send(self, client, data):
|
|
import base64
|
|
|
|
request_id = data.get("request_id")
|
|
dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
|
|
if err:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.send",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": err,
|
|
},
|
|
)
|
|
return
|
|
payload_b64 = data.get("payload_b64", "")
|
|
try:
|
|
payload = (
|
|
base64.b64decode(payload_b64, validate=True) if payload_b64 else b""
|
|
)
|
|
except Exception:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.send",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": "invalid_payload_b64",
|
|
},
|
|
)
|
|
return
|
|
ok, failure_reason = self.rns_link_manager.send_packet(
|
|
dest_hash,
|
|
aspect,
|
|
payload,
|
|
)
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.send",
|
|
"request_id": request_id,
|
|
"status": "success" if ok else "failure",
|
|
"failure_reason": failure_reason,
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
|
|
async def _handle_rns_link_close(self, client, data):
|
|
request_id = data.get("request_id")
|
|
dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
|
|
if err:
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.close",
|
|
"request_id": request_id,
|
|
"status": "failure",
|
|
"failure_reason": err,
|
|
},
|
|
)
|
|
return
|
|
ok = self.rns_link_manager.close(dest_hash, aspect)
|
|
await self._rns_link_send(
|
|
client,
|
|
{
|
|
"type": "rns.link.close",
|
|
"request_id": request_id,
|
|
"status": "success" if ok else "failure",
|
|
"failure_reason": None if ok else "no_active_link",
|
|
"destination_hash": dest_hash.hex(),
|
|
"aspect": aspect,
|
|
},
|
|
)
|
|
|
|
def _broadcast_to_websocket_clients(self, payload: dict) -> None:
|
|
"""Thread-safe fire-and-forget broadcast from RNS callback threads."""
|
|
try:
|
|
AsyncUtils.run_async(self.websocket_broadcast(json.dumps(payload)))
|
|
except Exception as e:
|
|
print(f"websocket broadcast failed: {e}")
|
|
|
|
def _on_rns_link_broadcast(self, payload: dict) -> None:
|
|
self._broadcast_to_websocket_clients(payload)
|
|
if payload.get("type") == "rns.link.event":
|
|
plugin_manager = getattr(self, "plugin_manager", None)
|
|
if plugin_manager is not None:
|
|
plugin_manager.on_rns_link_event(payload)
|
|
|
|
async def websocket_broadcast(self, data):
|
|
# Serialize: concurrent callers must not interleave. The second snapshot must run
|
|
# only after the first broadcast has finished mutating the live client list.
|
|
sessions_changed = False
|
|
async with self._websocket_broadcast_lock:
|
|
dead = []
|
|
# Iterate a copy: awaits allow other tasks to mutate self.websocket_clients.
|
|
for websocket_client in list(self.websocket_clients):
|
|
try:
|
|
await websocket_client.send_str(data)
|
|
except Exception as e:
|
|
print(f"Failed to broadcast to websocket client: {e}")
|
|
dead.append(websocket_client)
|
|
for client in dead:
|
|
try:
|
|
self.websocket_clients.remove(client)
|
|
except ValueError:
|
|
pass
|
|
if self._detach_active_session(client):
|
|
sessions_changed = True
|
|
try:
|
|
await client.close(code=WSCloseCode.GOING_AWAY)
|
|
except Exception:
|
|
pass
|
|
if sessions_changed:
|
|
await self.send_active_sessions_to_websocket_clients()
|
|
|
|
def _detach_active_session(self, websocket_response) -> bool:
|
|
session_id = getattr(websocket_response, "_meshchatx_session_id", None)
|
|
if not session_id:
|
|
return False
|
|
try:
|
|
delattr(websocket_response, "_meshchatx_session_id")
|
|
except Exception:
|
|
pass
|
|
return self.active_sessions.remove(session_id)
|
|
|
|
def get_active_sessions_payload(self) -> dict:
|
|
snap = self.active_sessions.snapshot()
|
|
warning_enabled = True
|
|
try:
|
|
cfg = getattr(self, "config", None)
|
|
if cfg is not None and hasattr(cfg, "multi_session_warning_enabled"):
|
|
warning_enabled = bool(cfg.multi_session_warning_enabled.get())
|
|
except Exception:
|
|
warning_enabled = True
|
|
count = int(snap.get("count") or 0)
|
|
return {
|
|
"count": count,
|
|
"sessions": list(snap.get("sessions") or []),
|
|
"warning": should_warn_multi_session(count, warning_enabled),
|
|
"warning_enabled": warning_enabled,
|
|
}
|
|
|
|
async def send_active_sessions_to_websocket_clients(self):
|
|
payload = self.get_active_sessions_payload()
|
|
payload["type"] = "app.sessions.updated"
|
|
await self.websocket_broadcast(json.dumps(payload))
|
|
|
|
# broadcasts config to all websocket clients
|
|
async def send_config_to_websocket_clients(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "config",
|
|
"config": self.get_config_dict(context=ctx),
|
|
},
|
|
),
|
|
)
|
|
|
|
# broadcasts to all websocket clients that we just announced
|
|
async def send_announced_to_websocket_clients(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announced",
|
|
"identity_hash": ctx.identity_hash,
|
|
"last_announced_at": ctx.config.last_announced_at.get(),
|
|
},
|
|
),
|
|
)
|
|
|
|
async def _broadcast_blocked_destinations(self):
|
|
try:
|
|
blocked = self.database.misc.get_blocked_destinations()
|
|
blocked_list = [
|
|
{
|
|
"destination_hash": b["destination_hash"],
|
|
"created_at": b["created_at"],
|
|
}
|
|
for b in blocked
|
|
]
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "blocked_destinations",
|
|
"blocked_destinations": blocked_list,
|
|
},
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"_broadcast_blocked_destinations: failed: {e}")
|
|
|
|
# returns a dictionary of config
|
|
def get_config_dict(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return {}
|
|
return {
|
|
"display_name": ctx.config.display_name.get(),
|
|
"identity_hash": ctx.identity.hash.hex(),
|
|
"identity_public_key": ctx.identity.get_public_key().hex(),
|
|
"lxmf_address_hash": ctx.local_lxmf_destination.hexhash,
|
|
"telephone_address_hash": ctx.telephone_manager.telephone.destination.hexhash
|
|
if ctx.telephone_manager.telephone
|
|
else None,
|
|
"is_transport_enabled": (
|
|
self.reticulum.transport_enabled()
|
|
if hasattr(self, "reticulum") and self.reticulum
|
|
else False
|
|
),
|
|
"auto_announce_enabled": ctx.config.auto_announce_enabled.get(),
|
|
"auto_announce_interval_seconds": ctx.config.auto_announce_interval_seconds.get(),
|
|
"last_announced_at": ctx.config.last_announced_at.get(),
|
|
"theme": ctx.config.theme.get(),
|
|
"language": ctx.config.language.get(),
|
|
"auto_resend_failed_messages_when_announce_received": ctx.config.auto_resend_failed_messages_when_announce_received.get(),
|
|
"allow_auto_resending_failed_messages_with_attachments": ctx.config.allow_auto_resending_failed_messages_with_attachments.get(),
|
|
"auto_send_failed_messages_to_propagation_node": ctx.config.auto_send_failed_messages_to_propagation_node.get(),
|
|
"show_suggested_community_interfaces": ctx.config.show_suggested_community_interfaces.get(),
|
|
"lxmf_delivery_transfer_limit_in_bytes": ctx.config.lxmf_delivery_transfer_limit_in_bytes.get(),
|
|
"lxmf_propagation_transfer_limit_in_bytes": ctx.config.lxmf_propagation_transfer_limit_in_bytes.get(),
|
|
"lxmf_propagation_sync_limit_in_bytes": ctx.config.lxmf_propagation_sync_limit_in_bytes.get(),
|
|
"lxmf_local_propagation_node_enabled": ctx.config.lxmf_local_propagation_node_enabled.get(),
|
|
"lxmf_local_propagation_node_address_hash": ctx.message_router.propagation_destination.hexhash,
|
|
"lxmf_preferred_propagation_node_destination_hash": ctx.config.lxmf_preferred_propagation_node_destination_hash.get(),
|
|
"lxmf_preferred_propagation_node_auto_select": ctx.config.lxmf_preferred_propagation_node_auto_select.get(),
|
|
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": ctx.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get(),
|
|
"lxmf_preferred_propagation_node_last_synced_at": ctx.config.lxmf_preferred_propagation_node_last_synced_at.get(),
|
|
"lxmf_user_icon_name": ctx.config.lxmf_user_icon_name.get(),
|
|
"lxmf_user_icon_foreground_colour": ctx.config.lxmf_user_icon_foreground_colour.get(),
|
|
"lxmf_user_icon_background_colour": ctx.config.lxmf_user_icon_background_colour.get(),
|
|
"lxmf_inbound_stamp_cost": ctx.config.lxmf_inbound_stamp_cost.get(),
|
|
"lxmf_propagation_node_stamp_cost": ctx.config.lxmf_propagation_node_stamp_cost.get(),
|
|
"lxmf_propagation_sequential_validation": ctx.config.lxmf_propagation_sequential_validation.get(),
|
|
"lxmf_propagation_static_peers_bypass_sequential": ctx.config.lxmf_propagation_static_peers_bypass_sequential.get(),
|
|
"lxmf_propagation_max_inbound_syncs": ctx.config.lxmf_propagation_max_inbound_syncs.get(),
|
|
"lxmf_flood_protection_enabled": ctx.config.lxmf_flood_protection_enabled.get(),
|
|
"lxmf_flood_threshold_per_minute": ctx.config.lxmf_flood_threshold_per_minute.get(),
|
|
"lxmf_flood_max_stamp_cost": ctx.config.lxmf_flood_max_stamp_cost.get(),
|
|
"lxmf_flood_cooldown_seconds": ctx.config.lxmf_flood_cooldown_seconds.get(),
|
|
"page_archiver_enabled": ctx.config.page_archiver_enabled.get(),
|
|
"page_archiver_max_versions": ctx.config.page_archiver_max_versions.get(),
|
|
"archives_max_storage_gb": ctx.config.archives_max_storage_gb.get(),
|
|
"backup_max_count": ctx.config.backup_max_count.get(),
|
|
"crawler_enabled": ctx.config.crawler_enabled.get(),
|
|
"crawler_max_retries": ctx.config.crawler_max_retries.get(),
|
|
"crawler_retry_delay_seconds": ctx.config.crawler_retry_delay_seconds.get(),
|
|
"crawler_max_concurrent": ctx.config.crawler_max_concurrent.get(),
|
|
"auth_enabled": self.auth_enabled,
|
|
"privacy_mode_enabled": ctx.config.privacy_mode_enabled.get(),
|
|
"multi_session_warning_enabled": ctx.config.multi_session_warning_enabled.get(),
|
|
"voicemail_enabled": ctx.config.voicemail_enabled.get(),
|
|
"voicemail_greeting": ctx.config.voicemail_greeting.get(),
|
|
"voicemail_auto_answer_delay_seconds": ctx.config.voicemail_auto_answer_delay_seconds.get(),
|
|
"voicemail_max_recording_seconds": ctx.config.voicemail_max_recording_seconds.get(),
|
|
"voicemail_tts_speed": ctx.config.voicemail_tts_speed.get(),
|
|
"voicemail_tts_pitch": ctx.config.voicemail_tts_pitch.get(),
|
|
"voicemail_tts_voice": ctx.config.voicemail_tts_voice.get(),
|
|
"voicemail_tts_word_gap": ctx.config.voicemail_tts_word_gap.get(),
|
|
"custom_ringtone_enabled": ctx.config.custom_ringtone_enabled.get(),
|
|
"ringtone_filename": ctx.config.ringtone_filename.get(),
|
|
"ringtone_preferred_id": ctx.config.ringtone_preferred_id.get(),
|
|
"ringtone_volume": ctx.config.ringtone_volume.get(),
|
|
"notification_sound_enabled": ctx.config.notification_sound_enabled.get(),
|
|
"notification_sound_preferred_id": ctx.config.notification_sound_preferred_id.get(),
|
|
"notification_sound_volume": ctx.config.notification_sound_volume.get(),
|
|
"map_offline_enabled": ctx.config.map_offline_enabled.get(),
|
|
"map_mbtiles_dir": ctx.config.map_mbtiles_dir.get(),
|
|
"map_tile_cache_enabled": ctx.config.map_tile_cache_enabled.get(),
|
|
"map_default_lat": ctx.config.map_default_lat.get(),
|
|
"map_default_lon": ctx.config.map_default_lon.get(),
|
|
"map_default_zoom": ctx.config.map_default_zoom.get(),
|
|
"map_tile_server_url": ctx.config.map_tile_server_url.get(),
|
|
"map_nominatim_api_url": ctx.config.map_nominatim_api_url.get(),
|
|
"map_overlay_max_bytes": ctx.config.map_overlay_max_bytes.get(),
|
|
"map_overlay_max_features": ctx.config.map_overlay_max_features.get(),
|
|
"map_overlay_max_kmz_uncompressed_bytes": ctx.config.map_overlay_max_kmz_uncompressed_bytes.get(),
|
|
"map_data_max_bytes": ctx.config.map_data_max_bytes.get(),
|
|
"map_data_announce_enabled": ctx.config.map_data_announce_enabled.get(),
|
|
"map_data_announce_interval": ctx.config.map_data_announce_interval.get(),
|
|
"map_data_display_name": ctx.config.map_data_display_name.get(),
|
|
"map_overlay_max_sources": ctx.config.map_overlay_max_sources.get(),
|
|
"map_overlay_max_concurrent_jobs": ctx.config.map_overlay_max_concurrent_jobs.get(),
|
|
"map_overlay_path_timeout_seconds": ctx.config.map_overlay_path_timeout_seconds.get(),
|
|
"map_overlay_transfer_timeout_seconds": ctx.config.map_overlay_transfer_timeout_seconds.get(),
|
|
"map_overlay_job_timeout_seconds": ctx.config.map_overlay_job_timeout_seconds.get(),
|
|
"map_overlay_max_retries": ctx.config.map_overlay_max_retries.get(),
|
|
"map_overlay_retry_delay_seconds": ctx.config.map_overlay_retry_delay_seconds.get(),
|
|
"do_not_disturb_enabled": ctx.config.do_not_disturb_enabled.get(),
|
|
"telephone_enabled": ctx.config.telephone_enabled.get(),
|
|
"telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(),
|
|
"telephone_announce_enabled": ctx.config.telephone_announce_enabled.get(),
|
|
"telephone_audio_profile_id": ctx.config.telephone_audio_profile_id.get(),
|
|
"telephone_call_mode_id": ctx.config.telephone_call_mode_id.get(),
|
|
"telephone_web_audio_enabled": ctx.config.telephone_web_audio_enabled.get(),
|
|
"telephone_web_audio_allow_fallback": ctx.config.telephone_web_audio_allow_fallback.get(),
|
|
"call_recording_enabled": ctx.config.call_recording_enabled.get(),
|
|
"block_attachments_from_strangers": ctx.config.block_attachments_from_strangers.get(),
|
|
"block_all_from_strangers": ctx.config.block_all_from_strangers.get(),
|
|
"show_unknown_contact_banner": ctx.config.show_unknown_contact_banner.get(),
|
|
"warn_on_stranger_links": ctx.config.warn_on_stranger_links.get(),
|
|
"banished_effect_enabled": ctx.config.banished_effect_enabled.get(),
|
|
"banished_text": ctx.config.banished_text.get(),
|
|
"banished_color": ctx.config.banished_color.get(),
|
|
"message_font_size": ctx.config.message_font_size.get(),
|
|
"messages_sidebar_position": ctx.config.messages_sidebar_position.get(),
|
|
"app_sidebar_layout": ctx.config.app_sidebar_layout.get(),
|
|
"messages_multi_pane_enabled": ctx.config.messages_multi_pane_enabled.get(),
|
|
"nomad_tabs_enabled": ctx.config.nomad_tabs_enabled.get(),
|
|
"rrc_enabled": ctx.config.rrc_enabled.get(),
|
|
"rrc_unread_badges_enabled": ctx.config.rrc_unread_badges_enabled.get(),
|
|
"message_icon_size": ctx.config.message_icon_size.get(),
|
|
"ui_transparency": ctx.config.ui_transparency.get(),
|
|
"ui_glass_enabled": ctx.config.ui_glass_enabled.get(),
|
|
"message_outbound_bubble_color": ctx.config.message_outbound_bubble_color.get(),
|
|
"message_inbound_bubble_color": ctx.config.message_inbound_bubble_color.get(),
|
|
"message_failed_bubble_color": ctx.config.message_failed_bubble_color.get(),
|
|
"message_waiting_bubble_color": ctx.config.message_waiting_bubble_color.get(),
|
|
"translator_argos_enabled": ctx.config.translator_argos_enabled.get(),
|
|
"translator_libretranslate_enabled": ctx.config.translator_libretranslate_enabled.get(),
|
|
"libretranslate_url": ctx.config.libretranslate_url.get(),
|
|
"libretranslate_api_key": ctx.config.libretranslate_api_key.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(),
|
|
"announce_store_lxmf_delivery": ctx.config.announce_store_lxmf_delivery.get(),
|
|
"announce_store_lxst_telephony": ctx.config.announce_store_lxst_telephony.get(),
|
|
"announce_store_nomadnetwork_node": ctx.config.announce_store_nomadnetwork_node.get(),
|
|
"announce_store_lxmf_propagation": ctx.config.announce_store_lxmf_propagation.get(),
|
|
"announce_store_map_data": ctx.config.announce_store_map_data.get(),
|
|
"announce_max_stored_lxmf_delivery": ctx.config.announce_max_stored_lxmf_delivery.get(),
|
|
"announce_max_stored_nomadnetwork_node": ctx.config.announce_max_stored_nomadnetwork_node.get(),
|
|
"announce_max_stored_lxmf_propagation": ctx.config.announce_max_stored_lxmf_propagation.get(),
|
|
"announce_max_stored_map_data": ctx.config.announce_max_stored_map_data.get(),
|
|
"announce_fetch_limit_lxmf_delivery": ctx.config.announce_fetch_limit_lxmf_delivery.get(),
|
|
"announce_fetch_limit_nomadnetwork_node": ctx.config.announce_fetch_limit_nomadnetwork_node.get(),
|
|
"announce_fetch_limit_lxmf_propagation": ctx.config.announce_fetch_limit_lxmf_propagation.get(),
|
|
"announce_fetch_limit_map_data": ctx.config.announce_fetch_limit_map_data.get(),
|
|
"announce_search_max_fetch": ctx.config.announce_search_max_fetch.get(),
|
|
"discovered_interfaces_max_return": ctx.config.discovered_interfaces_max_return.get(),
|
|
"csp_extra_connect_src": ctx.config.csp_extra_connect_src.get(),
|
|
"csp_extra_img_src": ctx.config.csp_extra_img_src.get(),
|
|
"csp_extra_frame_src": ctx.config.csp_extra_frame_src.get(),
|
|
"csp_extra_script_src": ctx.config.csp_extra_script_src.get(),
|
|
"csp_extra_style_src": ctx.config.csp_extra_style_src.get(),
|
|
"telephone_tone_generator_enabled": ctx.config.telephone_tone_generator_enabled.get(),
|
|
"telephone_tone_generator_volume": ctx.config.telephone_tone_generator_volume.get(),
|
|
"location_source": ctx.config.location_source.get(),
|
|
"location_manual_lat": ctx.config.location_manual_lat.get(),
|
|
"location_manual_lon": ctx.config.location_manual_lon.get(),
|
|
"location_manual_alt": ctx.config.location_manual_alt.get(),
|
|
"telemetry_enabled": ctx.config.telemetry_enabled.get(),
|
|
"nomad_render_markdown_enabled": ctx.config.nomad_render_markdown_enabled.get(),
|
|
"nomad_render_html_enabled": ctx.config.nomad_render_html_enabled.get(),
|
|
"nomad_render_plaintext_enabled": ctx.config.nomad_render_plaintext_enabled.get(),
|
|
"nomad_micron_wasm_enabled": ctx.config.nomad_micron_wasm_enabled.get(),
|
|
"nomad_micron_default_engine": ctx.config.nomad_micron_default_engine.get()
|
|
or "js",
|
|
"nomad_default_page_path": ctx.config.nomad_default_page_path.get(),
|
|
"local_message_auto_delete_enabled": ctx.config.local_message_auto_delete_enabled.get(),
|
|
"local_message_auto_delete_value": ctx.config.local_message_auto_delete_value.get(),
|
|
"local_message_auto_delete_unit": ctx.config.local_message_auto_delete_unit.get()
|
|
or "days",
|
|
"message_blocklist_enabled": ctx.config.message_blocklist_enabled.get(),
|
|
}
|
|
|
|
# try and get a name for the provided identity hash
|
|
def get_name_for_identity_hash(self, identity_hash: str):
|
|
id_norm = normalize_hex_identifier(identity_hash) if identity_hash else ""
|
|
# 1. try recall identity and calculate lxmf destination hash
|
|
identity = self.recall_identity(identity_hash)
|
|
if identity is not None:
|
|
# get lxmf.delivery destination hash
|
|
lxmf_destination_hash = RNS.Destination.hash(
|
|
identity,
|
|
"lxmf",
|
|
"delivery",
|
|
).hex()
|
|
|
|
# use custom name if available
|
|
custom_name = self.database.announces.get_custom_display_name(
|
|
lxmf_destination_hash,
|
|
)
|
|
if custom_name is not None:
|
|
return custom_name
|
|
|
|
# use lxmf name if available
|
|
lxmf_name = self.get_lxmf_conversation_name(
|
|
lxmf_destination_hash,
|
|
default_name=None,
|
|
)
|
|
if lxmf_name is not None:
|
|
return lxmf_name
|
|
|
|
# 2. if identity recall failed, or we couldn't find a name for the calculated hash
|
|
# try to look up an lxmf.delivery announce with this identity_hash in the database
|
|
lookup_hash = id_norm if id_norm else identity_hash
|
|
announces = self.database.announces.get_filtered_announces(
|
|
aspect="lxmf.delivery",
|
|
identity_hash=lookup_hash,
|
|
limit=5,
|
|
)
|
|
if announces:
|
|
for announce in announces:
|
|
ann_id = announce.get("identity_hash") or ""
|
|
if ann_id and normalize_hex_identifier(ann_id) == id_norm:
|
|
lxmf_destination_hash = announce["destination_hash"]
|
|
|
|
# check custom name for this hash
|
|
custom_name = self.database.announces.get_custom_display_name(
|
|
lxmf_destination_hash,
|
|
)
|
|
if custom_name is not None:
|
|
return custom_name
|
|
|
|
# check lxmf name from app_data
|
|
if announce["app_data"] is not None:
|
|
lxmf_name = parse_lxmf_display_name(
|
|
app_data_base64=announce["app_data"],
|
|
default_value=None,
|
|
)
|
|
if lxmf_name is not None:
|
|
return lxmf_name
|
|
|
|
# couldn't find a name for this identity
|
|
return None
|
|
|
|
# recall identity from reticulum or database
|
|
def get_lxmf_destination_hash_for_identity_hash(self, identity_hash: str):
|
|
id_norm = normalize_hex_identifier(identity_hash) if identity_hash else ""
|
|
identity = self.recall_identity(identity_hash)
|
|
if identity is not None:
|
|
return RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
|
|
|
# fallback to announces
|
|
lookup_hash = id_norm if id_norm else identity_hash
|
|
announces = self.database.announces.get_filtered_announces(
|
|
aspect="lxmf.delivery",
|
|
identity_hash=lookup_hash,
|
|
limit=5,
|
|
)
|
|
if announces:
|
|
for announce in announces:
|
|
ann_id = announce.get("identity_hash") or ""
|
|
if ann_id and normalize_hex_identifier(ann_id) == id_norm:
|
|
return announce["destination_hash"]
|
|
return None
|
|
|
|
def get_lxst_telephony_hash_for_identity_hash(self, identity_hash: str):
|
|
id_norm = normalize_hex_identifier(identity_hash) if identity_hash else ""
|
|
# Primary: use announces table for lxst.telephony aspect
|
|
lookup_hash = id_norm if id_norm else identity_hash
|
|
announces = self.database.announces.get_filtered_announces(
|
|
aspect="lxst.telephony",
|
|
identity_hash=lookup_hash,
|
|
limit=5,
|
|
)
|
|
if announces:
|
|
for announce in announces:
|
|
ann_id = announce.get("identity_hash") or ""
|
|
if ann_id and normalize_hex_identifier(ann_id) == id_norm:
|
|
return announce.get("destination_hash")
|
|
|
|
# Fallback: derive from identity if available (same identity, different aspect)
|
|
identity = self.recall_identity(identity_hash)
|
|
if identity is not None:
|
|
try:
|
|
return RNS.Destination.hash(identity, "lxst", "telephony").hex()
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def recall_identity(self, hash_hex: str) -> RNS.Identity | None:
|
|
try:
|
|
if not hash_hex or not isinstance(hash_hex, str):
|
|
return None
|
|
|
|
stripped = hash_hex.strip()
|
|
canonical = normalize_hex_identifier(stripped)
|
|
|
|
# 1. try reticulum recall (works for both identity and destination hashes)
|
|
hash_bytes = hex_identifier_to_bytes(stripped)
|
|
if hash_bytes:
|
|
identity = RNS.Identity.recall(hash_bytes)
|
|
if identity:
|
|
return identity
|
|
|
|
# 2. try database lookup
|
|
# lookup by destination hash first
|
|
announce = self.database.announces.get_announce_by_hash(stripped)
|
|
if not announce and canonical:
|
|
announce = self.database.announces.get_announce_by_hash(canonical)
|
|
if announce:
|
|
announce = dict(announce)
|
|
|
|
if not announce:
|
|
# lookup by identity hash
|
|
search_term = canonical if len(canonical) >= 8 else stripped
|
|
results = self.database.announces.get_filtered_announces(
|
|
search_term=search_term,
|
|
)
|
|
if results:
|
|
# find first one with a public key
|
|
for res in results:
|
|
res_dict = dict(res)
|
|
if res_dict.get("identity_public_key"):
|
|
announce = res_dict
|
|
break
|
|
|
|
if announce and announce.get("identity_public_key"):
|
|
public_key = base64.b64decode(announce["identity_public_key"])
|
|
identity = self._identity_from_public_key_bytes(public_key)
|
|
if identity is not None:
|
|
return identity
|
|
|
|
except Exception as e:
|
|
print(f"Error recalling identity for {hash_hex}: {type(e).__name__}: {e!r}")
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _identity_from_public_key_bytes(public_key: bytes) -> RNS.Identity | None:
|
|
"""Load an RNS Identity from raw public-key bytes.
|
|
|
|
Identity.load_public_key is documented as returning True/False, but
|
|
current RNS releases return None on both success and failure. Treat
|
|
a non-None identity.pub (and a computed hash) as success.
|
|
"""
|
|
if not public_key:
|
|
return None
|
|
identity = RNS.Identity(create_keys=False)
|
|
try:
|
|
identity.load_public_key(public_key)
|
|
except Exception:
|
|
return None
|
|
if getattr(identity, "pub", None) is None:
|
|
return None
|
|
if not getattr(identity, "hash", None):
|
|
return None
|
|
return identity
|
|
|
|
# convert an lxmf message to a dictionary, for sending over websocket
|
|
|
|
# convert database announce to a dictionary
|
|
def _batch_convert_announces_to_api_dicts(
|
|
self,
|
|
results,
|
|
aspect=None,
|
|
include_hops=True,
|
|
):
|
|
"""Batch-convert announce rows using prefetched icons and custom names."""
|
|
if not results:
|
|
return []
|
|
|
|
other_user_hashes = [r["destination_hash"] for r in results]
|
|
user_icons = {}
|
|
if other_user_hashes:
|
|
db_icons = self.database.misc.get_user_icons(other_user_hashes)
|
|
for icon in db_icons:
|
|
user_icons[icon["destination_hash"]] = {
|
|
"icon_name": icon["icon_name"],
|
|
"foreground_colour": icon["foreground_colour"],
|
|
"background_colour": icon["background_colour"],
|
|
}
|
|
|
|
custom_names = {}
|
|
lxmf_names_for_telephony = {}
|
|
if other_user_hashes:
|
|
db_custom_names = self.database.provider.fetchall(
|
|
f"SELECT destination_hash, display_name FROM custom_destination_display_names WHERE destination_hash IN ({','.join(['?'] * len(other_user_hashes))})",
|
|
other_user_hashes,
|
|
)
|
|
for row in db_custom_names:
|
|
custom_names[row["destination_hash"]] = row["display_name"]
|
|
|
|
if aspect == "lxst.telephony":
|
|
identity_hashes = list(
|
|
{r["identity_hash"] for r in results if r.get("identity_hash")},
|
|
)
|
|
if identity_hashes:
|
|
lxmf_results = self.database.announces.provider.fetchall(
|
|
f"SELECT identity_hash, app_data FROM announces WHERE aspect = 'lxmf.delivery' AND identity_hash IN ({','.join(['?'] * len(identity_hashes))})",
|
|
identity_hashes,
|
|
)
|
|
for row in lxmf_results:
|
|
lxmf_names_for_telephony[row["identity_hash"]] = (
|
|
parse_lxmf_display_name(row["app_data"])
|
|
)
|
|
|
|
all_announces = []
|
|
for announce in results:
|
|
if not isinstance(announce, dict):
|
|
announce = dict(announce)
|
|
|
|
display_name = None
|
|
is_local = (
|
|
self.current_context
|
|
and announce["identity_hash"] == self.current_context.identity_hash
|
|
)
|
|
|
|
if announce["aspect"] == "lxmf.delivery":
|
|
display_name = parse_lxmf_display_name(announce["app_data"])
|
|
elif announce["aspect"] == "nomadnetwork.node":
|
|
display_name = parse_nomadnetwork_node_display_name(
|
|
announce["app_data"],
|
|
)
|
|
elif announce["aspect"] == "lxst.telephony":
|
|
display_name = parse_lxmf_display_name(announce["app_data"])
|
|
if not display_name or display_name == "Anonymous Peer":
|
|
display_name = lxmf_names_for_telephony.get(
|
|
announce["identity_hash"],
|
|
)
|
|
elif announce["aspect"] == "rrc.hub":
|
|
display_name = rrc_protocol.display_name_from_hub_app_data(
|
|
announce.get("app_data"),
|
|
)
|
|
|
|
if not display_name or display_name == "Anonymous Peer":
|
|
if is_local and self.current_context:
|
|
display_name = self.current_context.config.display_name.get()
|
|
else:
|
|
display_name = (
|
|
self.get_name_for_identity_hash(announce["identity_hash"])
|
|
or "Anonymous Peer"
|
|
)
|
|
|
|
hops = None
|
|
if include_hops:
|
|
hops = RNS.Transport.hops_to(
|
|
bytes.fromhex(announce["destination_hash"]),
|
|
)
|
|
|
|
created_at = str(announce["created_at"])
|
|
if created_at and "+" not in created_at and "Z" not in created_at:
|
|
created_at += "Z"
|
|
updated_at = str(announce["updated_at"])
|
|
if updated_at and "+" not in updated_at and "Z" not in updated_at:
|
|
updated_at += "Z"
|
|
|
|
all_announces.append(
|
|
{
|
|
"id": announce["id"],
|
|
"destination_hash": announce["destination_hash"],
|
|
"aspect": announce["aspect"],
|
|
"identity_hash": announce["identity_hash"],
|
|
"identity_public_key": announce["identity_public_key"],
|
|
"app_data": announce["app_data"],
|
|
"hops": hops,
|
|
"rssi": announce["rssi"],
|
|
"snr": announce["snr"],
|
|
"quality": announce["quality"],
|
|
"created_at": created_at,
|
|
"updated_at": updated_at,
|
|
"display_name": display_name,
|
|
"custom_display_name": custom_names.get(
|
|
announce["destination_hash"],
|
|
),
|
|
"lxmf_user_icon": user_icons.get(announce["destination_hash"]),
|
|
"contact_image": announce.get("contact_image"),
|
|
},
|
|
)
|
|
return all_announces
|
|
|
|
def convert_db_announce_to_dict(self, announce):
|
|
# convert to dict if it's a sqlite3.Row
|
|
if not isinstance(announce, dict):
|
|
announce = dict(announce)
|
|
|
|
# parse display name from announce
|
|
display_name = None
|
|
if announce["aspect"] == "lxmf.delivery":
|
|
display_name = parse_lxmf_display_name(announce["app_data"])
|
|
elif announce["aspect"] == "nomadnetwork.node":
|
|
display_name = parse_nomadnetwork_node_display_name(
|
|
announce["app_data"],
|
|
)
|
|
elif announce["aspect"] == "lxst.telephony":
|
|
display_name = parse_lxmf_display_name(announce["app_data"])
|
|
elif announce["aspect"] == "rrc.hub":
|
|
display_name = rrc_protocol.display_name_from_hub_app_data(
|
|
announce.get("app_data"),
|
|
)
|
|
|
|
# Try to find associated LXMF destination hash if this is a telephony announce
|
|
lxmf_destination_hash = None
|
|
if announce["aspect"] == "lxst.telephony" and announce.get("identity_hash"):
|
|
# 1. Check if we already have an LXMF announce for this identity
|
|
lxmf_announces = self.database.announces.get_filtered_announces(
|
|
aspect="lxmf.delivery",
|
|
search_term=announce["identity_hash"],
|
|
)
|
|
if lxmf_announces:
|
|
for lxmf_a in lxmf_announces:
|
|
if lxmf_a["identity_hash"] == announce["identity_hash"]:
|
|
lxmf_destination_hash = lxmf_a["destination_hash"]
|
|
# Also update display name if telephony one was empty
|
|
if not display_name or display_name == "Anonymous Peer":
|
|
display_name = parse_lxmf_display_name(
|
|
lxmf_a["app_data"],
|
|
)
|
|
break
|
|
|
|
# 2. If not found in announces, try to recall identity and calculate LXMF hash
|
|
if not lxmf_destination_hash:
|
|
try:
|
|
identity_hash_bytes = bytes.fromhex(announce["identity_hash"])
|
|
identity = RNS.Identity.recall(identity_hash_bytes)
|
|
if not identity and announce.get("identity_public_key"):
|
|
# Try to load from public key if recall failed
|
|
public_key = base64.b64decode(announce["identity_public_key"])
|
|
identity = self._identity_from_public_key_bytes(public_key)
|
|
|
|
if identity:
|
|
try:
|
|
lxmf_destination_hash = RNS.Destination.hash(
|
|
identity,
|
|
"lxmf",
|
|
"delivery",
|
|
).hex()
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
if not display_name or display_name == "Anonymous Peer":
|
|
is_local = (
|
|
self.current_context
|
|
and announce.get("identity_hash") == self.current_context.identity_hash
|
|
)
|
|
if is_local and self.current_context:
|
|
display_name = self.current_context.config.display_name.get()
|
|
elif announce.get("identity_hash"):
|
|
display_name = (
|
|
self.get_name_for_identity_hash(announce["identity_hash"])
|
|
or "Anonymous Peer"
|
|
)
|
|
else:
|
|
display_name = "Anonymous Peer"
|
|
|
|
# find lxmf user icon from database
|
|
lxmf_user_icon = None
|
|
# Try multiple potential hashes for the icon
|
|
icon_hashes_to_check = []
|
|
if lxmf_destination_hash:
|
|
icon_hashes_to_check.append(lxmf_destination_hash)
|
|
icon_hashes_to_check.append(announce["destination_hash"])
|
|
|
|
# ensure we don't return the user's own icon for peers
|
|
local_hash = None
|
|
if self.current_context and self.current_context.local_lxmf_destination:
|
|
local_hash = self.current_context.local_lxmf_destination.hexhash
|
|
|
|
db_lxmf_user_icon = None
|
|
for icon_hash in icon_hashes_to_check:
|
|
# skip if this is the user's own hash - don't return user's icon for peers
|
|
if local_hash and icon_hash == local_hash:
|
|
continue
|
|
db_lxmf_user_icon = self.database.misc.get_user_icon(icon_hash)
|
|
if db_lxmf_user_icon:
|
|
break
|
|
|
|
if db_lxmf_user_icon:
|
|
lxmf_user_icon = {
|
|
"icon_name": db_lxmf_user_icon["icon_name"],
|
|
"foreground_colour": db_lxmf_user_icon["foreground_colour"],
|
|
"background_colour": db_lxmf_user_icon["background_colour"],
|
|
}
|
|
|
|
# get current hops away
|
|
hops = RNS.Transport.hops_to(bytes.fromhex(announce["destination_hash"]))
|
|
|
|
# ensure created_at and updated_at have Z suffix for UTC if they don't have a timezone
|
|
created_at = str(announce["created_at"])
|
|
if created_at and "+" not in created_at and "Z" not in created_at:
|
|
created_at += "Z"
|
|
|
|
updated_at = str(announce["updated_at"])
|
|
if updated_at and "+" not in updated_at and "Z" not in updated_at:
|
|
updated_at += "Z"
|
|
|
|
return {
|
|
"id": announce["id"],
|
|
"destination_hash": announce["destination_hash"],
|
|
"aspect": announce["aspect"],
|
|
"identity_hash": announce["identity_hash"],
|
|
"identity_public_key": announce["identity_public_key"],
|
|
"app_data": announce["app_data"],
|
|
"hops": hops,
|
|
"rssi": announce["rssi"],
|
|
"snr": announce["snr"],
|
|
"quality": announce["quality"],
|
|
"display_name": display_name,
|
|
"lxmf_destination_hash": lxmf_destination_hash,
|
|
"custom_display_name": self.get_custom_destination_display_name(
|
|
announce["destination_hash"],
|
|
),
|
|
"lxmf_user_icon": lxmf_user_icon,
|
|
"created_at": created_at,
|
|
"updated_at": updated_at,
|
|
}
|
|
|
|
# convert database lxmf message to a dictionary
|
|
# updates the lxmf user icon for the provided destination hash
|
|
def update_lxmf_user_icon(
|
|
self,
|
|
destination_hash: str,
|
|
icon_name: str,
|
|
foreground_colour: str,
|
|
background_colour: str,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# ensure we're not storing the user's own icon with a peer's hash
|
|
# only store icons for remote peers, not for the local user
|
|
if (
|
|
ctx.local_lxmf_destination
|
|
and destination_hash == ctx.local_lxmf_destination.hexhash
|
|
):
|
|
print(f"skipping icon update for local user's own hash: {destination_hash}")
|
|
return
|
|
|
|
# log
|
|
print(
|
|
f"updating lxmf user icon for {destination_hash} to icon_name={icon_name}, foreground_colour={foreground_colour}, background_colour={background_colour}",
|
|
)
|
|
|
|
ctx.database.misc.update_lxmf_user_icon(
|
|
destination_hash,
|
|
icon_name,
|
|
foreground_colour,
|
|
background_colour,
|
|
)
|
|
|
|
def _related_hashes_for_contact_lookup(self, source_hash: str, context=None):
|
|
"""Collect identity/LXMF/LXST hashes that may identify the same peer."""
|
|
ctx = context or self.current_context
|
|
related = []
|
|
seen = set()
|
|
|
|
def add(value):
|
|
if not value or not isinstance(value, str):
|
|
return
|
|
normalized = normalize_hex_identifier(value)
|
|
if not normalized or normalized in seen:
|
|
return
|
|
seen.add(normalized)
|
|
related.append(normalized)
|
|
|
|
add(source_hash)
|
|
if not ctx or not ctx.database:
|
|
return related
|
|
|
|
try:
|
|
announce = ctx.database.announces.get_announce_by_hash(source_hash)
|
|
if announce:
|
|
add(announce.get("identity_hash"))
|
|
add(announce.get("destination_hash"))
|
|
identity_hash = announce.get("identity_hash")
|
|
if identity_hash:
|
|
for other in ctx.database.announces.get_announces_by_identity_hash(
|
|
identity_hash,
|
|
):
|
|
add(other.get("destination_hash"))
|
|
add(other.get("identity_hash"))
|
|
else:
|
|
for other in ctx.database.announces.get_announces_by_identity_hash(
|
|
source_hash,
|
|
):
|
|
add(other.get("destination_hash"))
|
|
add(other.get("identity_hash"))
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(source_hash)
|
|
add(lxmf_hash)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
lxst_hash = self.get_lxst_telephony_hash_for_identity_hash(source_hash)
|
|
add(lxst_hash)
|
|
except Exception:
|
|
pass
|
|
|
|
return related
|
|
|
|
def _resolve_contact_for_hash(self, source_hash: str, context=None):
|
|
"""Resolve a contact for an identity or destination hash.
|
|
|
|
Contacts are often saved with an LXMF destination hash as
|
|
remote_identity_hash (from chat UI). Incoming calls provide the
|
|
caller's identity hash. Bridge those forms via announces and derived
|
|
destination hashes so contacts-only call policy works.
|
|
"""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database or not source_hash:
|
|
return None
|
|
try:
|
|
related = self._related_hashes_for_contact_lookup(source_hash, context=ctx)
|
|
return ctx.database.contacts.get_contact_by_identity_hash(
|
|
source_hash,
|
|
related_hashes=related,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
def _collect_blocked_identity_hashes(self, context=None) -> list:
|
|
"""Identity-hash bytes for LXST set_blocked from the block list."""
|
|
ctx = context or self.current_context
|
|
out = []
|
|
seen = set()
|
|
if not ctx or not ctx.database:
|
|
return out
|
|
try:
|
|
blocked = ctx.database.misc.get_blocked_destinations()
|
|
except Exception:
|
|
return out
|
|
|
|
for row in blocked or []:
|
|
dest_hex = row.get("destination_hash") if isinstance(row, dict) else None
|
|
if not dest_hex:
|
|
continue
|
|
candidates = [dest_hex]
|
|
try:
|
|
announce = ctx.database.announces.get_announce_by_hash(dest_hex)
|
|
if announce and announce.get("identity_hash"):
|
|
candidates.append(announce["identity_hash"])
|
|
except Exception:
|
|
pass
|
|
for candidate in candidates:
|
|
try:
|
|
raw = bytes.fromhex(str(candidate))
|
|
except Exception:
|
|
continue
|
|
if len(raw) != RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
|
|
continue
|
|
if raw in seen:
|
|
continue
|
|
seen.add(raw)
|
|
out.append(raw)
|
|
return out
|
|
|
|
def sync_telephone_call_policy(self, context=None):
|
|
"""Push contacts-only / DND / block policy into LXST Telephone.set_allowed.
|
|
|
|
This rejects unauthorized callers before RINGING instead of relying only
|
|
on a delayed hangup after the ringing callback.
|
|
"""
|
|
ctx = context or self.current_context
|
|
if not ctx or not getattr(ctx, "telephone_manager", None):
|
|
return
|
|
|
|
def allowed(identity_hash: bytes, policy_ctx=ctx) -> bool:
|
|
if not isinstance(identity_hash, (bytes, bytearray)):
|
|
return False
|
|
caller_hex = bytes(identity_hash).hex()
|
|
try:
|
|
if policy_ctx.config.do_not_disturb_enabled.get():
|
|
return False
|
|
if self.is_destination_blocked(caller_hex, context=policy_ctx):
|
|
return False
|
|
if (
|
|
policy_ctx.config.telephone_allow_calls_from_contacts_only.get()
|
|
or policy_ctx.config.block_all_from_strangers.get()
|
|
) and not self._is_contact(caller_hex, context=policy_ctx):
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
print(f"sync_telephone_call_policy allowed() error: {e}")
|
|
return False
|
|
|
|
try:
|
|
ctx.telephone_manager.set_call_policy(
|
|
allowed_fn=allowed,
|
|
blocked_identity_hashes=self._collect_blocked_identity_hashes(
|
|
context=ctx,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"sync_telephone_call_policy failed: {e}")
|
|
|
|
def _is_contact(self, source_hash: str, context=None) -> bool:
|
|
return self._resolve_contact_for_hash(source_hash, context=context) is not None
|
|
|
|
def _encode_pcm_wav_to_ogg_opus(self, wav_bytes: bytes) -> bytes | None:
|
|
"""Encode a WAV/PCM payload into an OGG/Opus byte string.
|
|
|
|
Thin compatibility wrapper around
|
|
meshchatx.src.backend.audio_codec.encode_audio_bytes_to_ogg_opus
|
|
kept for the existing test surface.
|
|
"""
|
|
try:
|
|
from meshchatx.src.backend import audio_codec
|
|
|
|
return audio_codec.encode_audio_bytes_to_ogg_opus(wav_bytes)
|
|
except Exception as e:
|
|
print(f"PCM->OGG/Opus encoding via LXST failed: {e}")
|
|
return None
|
|
|
|
def _convert_webm_opus_to_ogg(self, audio_bytes: bytes) -> bytes:
|
|
"""Convert browser-recorded audio into LXMF-compatible OGG/Opus.
|
|
|
|
Routes everything through
|
|
meshchatx.src.backend.audio_codec, which decodes the input
|
|
with miniaudio (WAV/MP3/FLAC/OGG-Vorbis) or LXST (OGG/Opus) and
|
|
re-encodes it with LXST's voice-friendly Opus profile. If decoding
|
|
fails the original bytes are returned unchanged so the caller can
|
|
still try to send the message.
|
|
"""
|
|
if audio_bytes[:4] == b"OggS":
|
|
return audio_bytes
|
|
try:
|
|
from meshchatx.src.backend import audio_codec
|
|
|
|
encoded = audio_codec.encode_audio_bytes_to_ogg_opus(audio_bytes)
|
|
except Exception as e:
|
|
print(f"audio conversion failed: {e}")
|
|
return audio_bytes
|
|
if encoded is None:
|
|
return audio_bytes
|
|
return encoded
|
|
|
|
def incoming_call_is_policy_filtered(self, caller_hex, context=None) -> bool:
|
|
"""True when an inbound ring must not surface in the local UI.
|
|
|
|
Covers DND, banishment, contacts-only, and block-strangers. Missing
|
|
caller identity is treated as filtered when a contact gate is on.
|
|
Unexpected errors fail closed.
|
|
"""
|
|
ctx = context or self.current_context
|
|
if not ctx or not getattr(ctx, "config", None):
|
|
return True
|
|
try:
|
|
if ctx.config.do_not_disturb_enabled.get():
|
|
return True
|
|
if caller_hex and self.is_destination_blocked(caller_hex, context=ctx):
|
|
return True
|
|
if (
|
|
ctx.config.telephone_allow_calls_from_contacts_only.get()
|
|
or ctx.config.block_all_from_strangers.get()
|
|
) and (not caller_hex or not self._is_contact(caller_hex, context=ctx)):
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
print(f"incoming_call_is_policy_filtered: {e}")
|
|
return True
|
|
|
|
def is_destination_blocked(self, destination_hash: str, context=None) -> bool:
|
|
"""Return whether destination_hash is in the block list.
|
|
|
|
Accepts either a destination hash or an identity hash. A block on the
|
|
identity matches every known destination of that identity, and a block
|
|
on any destination matches the identity. Unexpected database errors
|
|
fail closed so inbound LXMF and LXST do not treat a broken ACL as open.
|
|
"""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return False
|
|
try:
|
|
related = self._related_hashes_for_contact_lookup(
|
|
destination_hash,
|
|
context=ctx,
|
|
)
|
|
if destination_hash and destination_hash not in related:
|
|
related = [destination_hash, *related]
|
|
for peer_hash in related:
|
|
if ctx.database.misc.is_destination_blocked(peer_hash):
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return True
|
|
|
|
def _lxmf_reticulum_enforce_block(
|
|
self,
|
|
destination_hash: str,
|
|
context=None,
|
|
) -> None:
|
|
"""Apply Reticulum blackhole or drop_path after a peer was added to the block list."""
|
|
ctx = context or self.current_context
|
|
try:
|
|
if not hasattr(self, "reticulum") or not self.reticulum:
|
|
return
|
|
db = getattr(ctx, "database", None) if ctx is not None else None
|
|
if db is None:
|
|
db = self.database
|
|
identity_hash = None
|
|
if db is not None:
|
|
announce = db.announces.get_announce_by_hash(destination_hash)
|
|
if announce and announce.get("identity_hash"):
|
|
identity_hash = announce["identity_hash"]
|
|
else:
|
|
by_ident = db.announces.get_announces_by_identity_hash(
|
|
destination_hash,
|
|
)
|
|
try:
|
|
by_ident = list(by_ident or [])
|
|
except TypeError:
|
|
by_ident = []
|
|
if by_ident:
|
|
identity_hash = destination_hash
|
|
target_hash = identity_hash or destination_hash
|
|
dest_bytes = bytes.fromhex(target_hash)
|
|
use_blackhole = True
|
|
cfg = getattr(ctx, "config", None) if ctx is not None else None
|
|
if cfg is None:
|
|
cfg = self.config
|
|
if cfg is not None:
|
|
try:
|
|
use_blackhole = bool(cfg.blackhole_integration_enabled.get())
|
|
except Exception:
|
|
use_blackhole = True
|
|
if use_blackhole and 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:
|
|
self.reticulum.drop_path(dest_bytes)
|
|
except Exception as e:
|
|
print(f"_lxmf_reticulum_enforce_block: failed: {e}")
|
|
|
|
def _delete_contact_and_stamp_ticket(
|
|
self,
|
|
destination_hash: str,
|
|
context=None,
|
|
) -> None:
|
|
"""Remove contact and stamp/ticket state for a blocked destination."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
try:
|
|
contact = self._resolve_contact_for_hash(destination_hash, context=ctx)
|
|
if contact and contact.get("id"):
|
|
ctx.database.contacts.delete_contact(contact["id"])
|
|
except Exception as e:
|
|
print(f"_delete_contact_and_stamp_ticket: contact delete failed: {e}")
|
|
|
|
try:
|
|
# Remove stamp costs and tickets from LXMRouter
|
|
if ctx.message_router:
|
|
dest_bytes = bytes.fromhex(destination_hash)
|
|
# Remove outbound stamp cost
|
|
if hasattr(ctx.message_router, "outbound_stamp_costs"):
|
|
ctx.message_router.outbound_stamp_costs.pop(dest_bytes, None)
|
|
# Remove tickets
|
|
if hasattr(ctx.message_router, "available_tickets"):
|
|
ctx.message_router.available_tickets["outbound"].pop(
|
|
dest_bytes,
|
|
None,
|
|
)
|
|
ctx.message_router.available_tickets["inbound"].pop(
|
|
dest_bytes,
|
|
None,
|
|
)
|
|
ctx.message_router.available_tickets["last_deliveries"].pop(
|
|
dest_bytes,
|
|
None,
|
|
)
|
|
# Persist changes
|
|
if hasattr(ctx.message_router, "save_outbound_stamp_costs"):
|
|
ctx.message_router.save_outbound_stamp_costs()
|
|
if hasattr(ctx.message_router, "save_available_tickets"):
|
|
ctx.message_router.save_available_tickets()
|
|
except Exception as e:
|
|
print(f"_delete_contact_and_stamp_ticket: stamp/ticket cleanup failed: {e}")
|
|
|
|
def _peer_hashes_for_banishment(self, destination_hash: str, context=None) -> list:
|
|
"""Identity and destination hashes that must be blocked or unblocked together."""
|
|
destination_hash = normalize_hex_identifier(destination_hash)
|
|
related = self._related_hashes_for_contact_lookup(
|
|
destination_hash,
|
|
context=context,
|
|
)
|
|
hashes = []
|
|
seen = set()
|
|
for peer_hash in (destination_hash, *related):
|
|
if not peer_hash or peer_hash in seen:
|
|
continue
|
|
if len(peer_hash) != 32:
|
|
continue
|
|
seen.add(peer_hash)
|
|
hashes.append(peer_hash)
|
|
return hashes
|
|
|
|
def banish_lxmf_peer(self, destination_hash: str, context=None) -> None:
|
|
"""Banish a peer by identity: persist every known dest, blackhole, wipe history."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
destination_hash = normalize_hex_identifier(destination_hash)
|
|
if not destination_hash or len(destination_hash) != 32:
|
|
return
|
|
hashes = self._peer_hashes_for_banishment(destination_hash, context=ctx)
|
|
try:
|
|
for peer_hash in hashes:
|
|
ctx.database.misc.add_blocked_destination(peer_hash)
|
|
self._delete_contact_and_stamp_ticket(peer_hash, context=ctx)
|
|
except Exception as e:
|
|
print(f"banish_lxmf_peer: failed: {e}")
|
|
return
|
|
self._lxmf_reticulum_enforce_block(destination_hash, context=ctx)
|
|
handler = getattr(ctx, "message_handler", None)
|
|
local_dest = getattr(ctx, "local_lxmf_destination", None)
|
|
if handler is not None and local_dest is not None:
|
|
try:
|
|
local_hash = local_dest.hash.hex()
|
|
for peer_hash in hashes:
|
|
handler.delete_conversation(local_hash, peer_hash)
|
|
except Exception as e:
|
|
print(f"banish_lxmf_peer: conversation delete failed: {e}")
|
|
AsyncUtils.run_async(self._broadcast_blocked_destinations())
|
|
self.sync_telephone_call_policy(context=ctx)
|
|
|
|
def lift_lxmf_peer_banishment(self, destination_hash: str, context=None) -> None:
|
|
"""Lift banishment for an identity and every known destination hash."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
destination_hash = normalize_hex_identifier(destination_hash)
|
|
if not destination_hash or len(destination_hash) != 32:
|
|
return
|
|
hashes = self._peer_hashes_for_banishment(destination_hash, context=ctx)
|
|
for peer_hash in hashes:
|
|
try:
|
|
ctx.database.misc.delete_blocked_destination(peer_hash)
|
|
except Exception as e:
|
|
print(f"lift_lxmf_peer_banishment: delete {peer_hash} failed: {e}")
|
|
try:
|
|
if (
|
|
hasattr(self, "reticulum")
|
|
and self.reticulum
|
|
and hasattr(self.reticulum, "unblackhole_identity")
|
|
):
|
|
seen = set()
|
|
for peer_hash in hashes:
|
|
raw = hex_identifier_to_bytes(peer_hash)
|
|
if not raw or raw in seen:
|
|
continue
|
|
seen.add(raw)
|
|
try:
|
|
self.reticulum.unblackhole_identity(raw)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Failed to unblackhole identity in Reticulum: {e}")
|
|
AsyncUtils.run_async(self._broadcast_blocked_destinations())
|
|
self.sync_telephone_call_policy(context=ctx)
|
|
|
|
def check_spam_keywords(self, title: str, content: str, context=None) -> bool:
|
|
"""Return whether title/content match configured spam keywords."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return False
|
|
try:
|
|
return ctx.database.misc.check_spam_keywords(title, content)
|
|
except Exception:
|
|
return False
|
|
|
|
def _apply_lxmf_flood_stamp_cost(self, cost: int, context=None) -> None:
|
|
"""Apply the given inbound stamp cost for flood protection and re-announce."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.message_router or not ctx.local_lxmf_destination:
|
|
return
|
|
cost = max(0, min(254, cost))
|
|
if cost < 1:
|
|
cost = 0
|
|
ctx.config.lxmf_inbound_stamp_cost.set(cost)
|
|
ctx.message_router.set_inbound_stamp_cost(
|
|
ctx.local_lxmf_destination.hash,
|
|
cost,
|
|
)
|
|
if cost > 0:
|
|
ctx.message_router.enforce_stamps()
|
|
elif hasattr(ctx.message_router, "ignore_stamps"):
|
|
ctx.message_router.ignore_stamps()
|
|
try:
|
|
ctx.local_lxmf_destination.display_name = ctx.config.display_name.get()
|
|
ctx.message_router.announce(
|
|
destination_hash=ctx.local_lxmf_destination.hash,
|
|
)
|
|
except Exception as e:
|
|
print(f"_apply_lxmf_flood_stamp_cost: re-announce failed: {e}")
|
|
|
|
def _check_lxmf_flood_protection(self, context=None) -> None:
|
|
"""Check incoming LXMF message rate and auto-adjust stamp cost if flooding."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.config:
|
|
return
|
|
if not ctx.config.lxmf_flood_protection_enabled.get():
|
|
return
|
|
# Do not interfere when block strangers is active (it uses max stamp)
|
|
if ctx.config.block_all_from_strangers.get():
|
|
return
|
|
|
|
now = time.time()
|
|
self._lxmf_incoming_timestamps = prune_lxmf_incoming_timestamps(
|
|
self._lxmf_incoming_timestamps,
|
|
now=now,
|
|
)
|
|
msgs_per_minute = len(
|
|
[t for t in self._lxmf_incoming_timestamps if now - t <= 60.0],
|
|
)
|
|
|
|
threshold = ctx.config.lxmf_flood_threshold_per_minute.get()
|
|
max_cost = ctx.config.lxmf_flood_max_stamp_cost.get()
|
|
current_cost = ctx.config.lxmf_inbound_stamp_cost.get()
|
|
current_cost = max(current_cost, 0)
|
|
|
|
# Determine base cost (the normal non-flood cost)
|
|
if self._flood_protection_current_cost is not None:
|
|
base_cost = self._flood_protection_current_cost
|
|
else:
|
|
base_cost = current_cost
|
|
|
|
if msgs_per_minute > threshold:
|
|
# Flood detected: bump stamp cost
|
|
new_cost = min(current_cost + 2, max_cost)
|
|
if new_cost != current_cost:
|
|
print(
|
|
f"LXMF flood detected: {msgs_per_minute} msg/min "
|
|
f"(threshold {threshold}). Raising stamp cost from "
|
|
f"{current_cost} to {new_cost}.",
|
|
)
|
|
if self._flood_protection_current_cost is None:
|
|
self._flood_protection_current_cost = base_cost
|
|
self._flood_protection_last_bump_time = now
|
|
self._apply_lxmf_flood_stamp_cost(new_cost, context=ctx)
|
|
elif current_cost > base_cost:
|
|
cooldown = ctx.config.lxmf_flood_cooldown_seconds.get()
|
|
if now - self._flood_protection_last_bump_time > cooldown:
|
|
# Step down by 1 toward base cost
|
|
new_cost = max(current_cost - 1, base_cost)
|
|
if new_cost != current_cost:
|
|
print(
|
|
f"LXMF flood subsided: {msgs_per_minute} msg/min. "
|
|
f"Lowering stamp cost from {current_cost} to {new_cost}.",
|
|
)
|
|
self._apply_lxmf_flood_stamp_cost(new_cost, context=ctx)
|
|
if new_cost == base_cost:
|
|
self._flood_protection_current_cost = None
|
|
self._flood_protection_last_bump_time = 0
|
|
|
|
async def lxmf_flood_protection_cooldown_loop(self, session_id, context=None):
|
|
"""Background loop to step down flood protection stamp cost during quiet periods."""
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
await asyncio.sleep(60)
|
|
while self.running and ctx.running and ctx.session_id == session_id:
|
|
try:
|
|
self._check_lxmf_flood_protection(context=ctx)
|
|
except Exception as e:
|
|
print(f"lxmf_flood_protection_cooldown_loop error: {e}")
|
|
await asyncio.sleep(30)
|
|
|
|
def _collect_lxmf_sieve_peer_haystack(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
contact=None,
|
|
) -> str:
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return ""
|
|
parts: list[str] = []
|
|
nm = self.get_lxmf_conversation_name(peer_hash, default_name="")
|
|
if nm:
|
|
parts.append(str(nm))
|
|
custom = self.get_custom_destination_display_name(peer_hash)
|
|
if custom:
|
|
parts.append(str(custom))
|
|
if contact is None:
|
|
contact = ctx.database.contacts.get_contact_by_identity_hash(peer_hash)
|
|
if contact:
|
|
if contact.get("name"):
|
|
parts.append(str(contact["name"]))
|
|
if contact.get("lxmf_address"):
|
|
parts.append(str(contact["lxmf_address"]))
|
|
return " ".join(parts)
|
|
|
|
def _lxmf_sieve_message_haystack(
|
|
self,
|
|
message_title: str | bytes | None,
|
|
message_content: str | bytes | None,
|
|
) -> str | None:
|
|
if message_title is None and message_content is None:
|
|
return None
|
|
|
|
def norm(x):
|
|
if x is None:
|
|
return ""
|
|
if isinstance(x, bytes):
|
|
return x.decode("utf-8", errors="replace")
|
|
return str(x)
|
|
|
|
t = norm(message_title).strip()
|
|
c = norm(message_content).strip()
|
|
if not t and not c:
|
|
return ""
|
|
return f"{t} {c}".strip()
|
|
|
|
def _evaluate_lxmf_sieve_for_peer(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.config:
|
|
return None
|
|
raw = ctx.config.lxmf_sieve_filters_json.get()
|
|
rules = parse_lxmf_sieve_filters_json(raw)
|
|
contact = ctx.database.contacts.get_contact_by_identity_hash(peer_hash)
|
|
is_contact = bool(contact)
|
|
haystack = self._collect_lxmf_sieve_peer_haystack(
|
|
peer_hash,
|
|
context=ctx,
|
|
contact=contact,
|
|
)
|
|
msg_hs = self._lxmf_sieve_message_haystack(message_title, message_content)
|
|
return first_matching_lxmf_sieve_rule(
|
|
rules,
|
|
haystack,
|
|
is_contact=is_contact,
|
|
message_haystack=msg_hs,
|
|
)
|
|
|
|
def _lxmf_sieve_suppresses_notifications(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
) -> bool:
|
|
m = self._evaluate_lxmf_sieve_for_peer(
|
|
peer_hash,
|
|
context=context,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
if not m:
|
|
return False
|
|
return m.get("action") in ("hide", "ignore", "banish")
|
|
|
|
def _lxmf_sieve_hides_peer(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
) -> bool:
|
|
m = self._evaluate_lxmf_sieve_for_peer(
|
|
peer_hash,
|
|
context=context,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
return bool(m and m.get("action") == "hide")
|
|
|
|
def _apply_lxmf_sieve_folder_rule(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
):
|
|
m = self._evaluate_lxmf_sieve_for_peer(
|
|
peer_hash,
|
|
context=context,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
if not m or m.get("action") != "folder":
|
|
return
|
|
fid = m.get("folder_id")
|
|
if fid is None:
|
|
return
|
|
try:
|
|
fid_int = int(fid)
|
|
except (TypeError, ValueError):
|
|
return
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
try:
|
|
ctx.database.messages.move_conversation_to_folder(peer_hash, fid_int)
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_lxmf_sieve_banish_rule(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
):
|
|
m = self._evaluate_lxmf_sieve_for_peer(
|
|
peer_hash,
|
|
context=context,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
if not m or m.get("action") != "banish":
|
|
return
|
|
self.banish_lxmf_peer(peer_hash, context=context)
|
|
|
|
def _apply_message_blocklist_banish_rule(
|
|
self,
|
|
peer_hash: str,
|
|
context=None,
|
|
*,
|
|
message_title=None,
|
|
message_content=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.config:
|
|
return
|
|
if not ctx.config.message_blocklist_enabled.get():
|
|
return
|
|
raw = ctx.config.message_blocklist_json.get()
|
|
blocklist = parse_message_blocklist_json(raw)
|
|
contact = None
|
|
is_contact = False
|
|
if ctx.database:
|
|
contact = ctx.database.contacts.get_contact_by_identity_hash(peer_hash)
|
|
is_contact = bool(contact)
|
|
haystack = self._collect_lxmf_sieve_peer_haystack(
|
|
peer_hash,
|
|
context=ctx,
|
|
contact=contact,
|
|
)
|
|
msg_hs = self._lxmf_sieve_message_haystack(message_title, message_content)
|
|
match = first_matching_blocklist_entry(
|
|
blocklist,
|
|
haystack,
|
|
is_contact=is_contact,
|
|
message_haystack=msg_hs,
|
|
)
|
|
if not match:
|
|
return
|
|
print(
|
|
f"Message blocklist matched entry {match.get('entry_id')} for {peer_hash}; banishing",
|
|
)
|
|
self.banish_lxmf_peer(peer_hash, context=ctx)
|
|
|
|
def on_lxmf_delivery(self, lxmf_message: LXMF.LXMessage, context=None):
|
|
"""Handle inbound LXMF delivery from Reticulum (synchronous callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.database:
|
|
logger.warning(
|
|
"Dropping inbound LXMF delivery: context not ready "
|
|
"(ctx=%s running=%s database=%s)",
|
|
ctx is not None,
|
|
getattr(ctx, "running", None) if ctx else None,
|
|
ctx.database is not None if ctx else None,
|
|
)
|
|
return
|
|
|
|
try:
|
|
source_hash = lxmf_message.source_hash.hex()
|
|
|
|
# check if source is blocked - reject immediately
|
|
if self.is_destination_blocked(source_hash, context=ctx):
|
|
print(f"Rejecting LXMF message from blocked source: {source_hash}")
|
|
return
|
|
|
|
# track incoming message timestamps for flood protection
|
|
self._lxmf_incoming_timestamps.append(time.time())
|
|
self._lxmf_incoming_timestamps = prune_lxmf_incoming_timestamps(
|
|
self._lxmf_incoming_timestamps,
|
|
)
|
|
self._check_lxmf_flood_protection(context=ctx)
|
|
|
|
if ctx.config.block_all_from_strangers.get() and not self._is_contact(
|
|
source_hash,
|
|
context=ctx,
|
|
):
|
|
print(
|
|
f"Blocking entire message from stranger: {source_hash}",
|
|
)
|
|
return
|
|
|
|
is_sideband_telemetry_request = False
|
|
lxmf_fields = lxmf_message.get_fields()
|
|
|
|
# check both standard LXMF.FIELD_COMMANDS (9) and FIELD_COMMANDS (1)
|
|
commands = []
|
|
if LXMF.FIELD_COMMANDS in lxmf_fields:
|
|
val = lxmf_fields[LXMF.FIELD_COMMANDS]
|
|
if isinstance(val, list):
|
|
commands.extend(val)
|
|
elif isinstance(val, dict):
|
|
commands.append(val)
|
|
if 0x01 in lxmf_fields and LXMF.FIELD_COMMANDS != 0x01:
|
|
val = lxmf_fields[0x01]
|
|
if isinstance(val, list):
|
|
commands.extend(val)
|
|
elif isinstance(val, dict):
|
|
commands.append(val)
|
|
|
|
if commands:
|
|
for command in commands:
|
|
if (
|
|
(
|
|
isinstance(command, dict)
|
|
and (
|
|
SidebandCommands.TELEMETRY_REQUEST in command
|
|
or str(SidebandCommands.TELEMETRY_REQUEST) in command
|
|
or f"0x{SidebandCommands.TELEMETRY_REQUEST:02x}"
|
|
in command
|
|
)
|
|
)
|
|
or (
|
|
isinstance(command, (list, tuple))
|
|
and SidebandCommands.TELEMETRY_REQUEST in command
|
|
)
|
|
or command == SidebandCommands.TELEMETRY_REQUEST
|
|
or str(command) == str(SidebandCommands.TELEMETRY_REQUEST)
|
|
):
|
|
is_sideband_telemetry_request = True
|
|
if (
|
|
isinstance(command, dict)
|
|
and SidebandCommands.PLUGIN_COMMAND in command
|
|
and getattr(lxmf_message, "signature_validated", False)
|
|
):
|
|
plugin_command = command.get(SidebandCommands.PLUGIN_COMMAND)
|
|
if isinstance(plugin_command, bytes):
|
|
plugin_command = plugin_command.decode(
|
|
"utf-8",
|
|
errors="replace",
|
|
)
|
|
if isinstance(plugin_command, str) and plugin_command.strip():
|
|
try:
|
|
self.sideband_plugin_loader.handle_plugin_command(
|
|
plugin_command,
|
|
lxmf_message,
|
|
)
|
|
except Exception as exc:
|
|
print(f"Sideband plugin command failed: {exc}")
|
|
|
|
# respond to telemetry requests
|
|
if is_sideband_telemetry_request:
|
|
# Check if telemetry is enabled globally
|
|
if not ctx.config.telemetry_enabled.get():
|
|
print(f"Telemetry is disabled, ignoring request from {source_hash}")
|
|
else:
|
|
# Check if peer is trusted
|
|
contact = ctx.database.contacts.get_contact_by_identity_hash(
|
|
source_hash,
|
|
)
|
|
if not contact or not contact.get("is_telemetry_trusted"):
|
|
print(
|
|
f"Telemetry request from untrusted peer {source_hash}, ignoring",
|
|
)
|
|
else:
|
|
lat, lon = self._resolve_location_for_telemetry()
|
|
if lat is not None and lon is not None:
|
|
print(f"Responding to telemetry request from {source_hash}")
|
|
self.handle_telemetry_request(source_hash)
|
|
else:
|
|
if not hasattr(self, "_telemetry_no_location_warned"):
|
|
self._telemetry_no_location_warned = set()
|
|
if source_hash not in self._telemetry_no_location_warned:
|
|
if len(self._telemetry_no_location_warned) >= 256:
|
|
self._telemetry_no_location_warned.clear()
|
|
self._telemetry_no_location_warned.add(source_hash)
|
|
print(
|
|
f"Cannot respond to telemetry request from {source_hash}: No location set. "
|
|
"Set manual coordinates in Settings > Location to respond.",
|
|
)
|
|
|
|
self.db_upsert_lxmf_message(lxmf_message, context=ctx)
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf.delivery",
|
|
"remote_identity_name": source_hash[:8],
|
|
"lxmf_message": convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
return
|
|
|
|
# check for spam keywords
|
|
is_spam = False
|
|
message_title = lxmf_message.title if hasattr(lxmf_message, "title") else ""
|
|
message_content = (
|
|
lxmf_message.content if hasattr(lxmf_message, "content") else ""
|
|
)
|
|
if isinstance(message_content, bytes):
|
|
message_content = message_content.decode("utf-8", errors="replace")
|
|
elif message_content is None:
|
|
message_content = ""
|
|
if isinstance(message_title, bytes):
|
|
message_title = message_title.decode("utf-8", errors="replace")
|
|
elif message_title is None:
|
|
message_title = ""
|
|
|
|
is_reaction_delivery = lxmf_fields_are_reaction(lxmf_fields)
|
|
|
|
# check spam keywords
|
|
if not is_reaction_delivery and self.check_spam_keywords(
|
|
message_title,
|
|
message_content,
|
|
context=ctx,
|
|
):
|
|
is_spam = True
|
|
print(
|
|
f"Marking LXMF message as spam due to keyword match: {source_hash}",
|
|
)
|
|
|
|
# reject attachments from blocked sources (already checked above, but double-check)
|
|
attachments_stripped = False
|
|
if has_attachments(lxmf_fields):
|
|
if self.is_destination_blocked(source_hash, context=ctx):
|
|
print(
|
|
f"Rejecting LXMF message with attachments from blocked source: {source_hash}",
|
|
)
|
|
return
|
|
# reject attachments from spam sources
|
|
if is_spam:
|
|
print(
|
|
f"Rejecting LXMF message with attachments from spam source: {source_hash}",
|
|
)
|
|
return
|
|
# strip attachments from strangers (non-contacts) if setting is enabled
|
|
if (
|
|
ctx.config.block_attachments_from_strangers.get()
|
|
and not self._is_contact(source_hash, context=ctx)
|
|
):
|
|
for key in (
|
|
LXMF.FIELD_FILE_ATTACHMENTS,
|
|
LXMF.FIELD_IMAGE,
|
|
LXMF.FIELD_AUDIO,
|
|
):
|
|
if key in lxmf_fields:
|
|
del lxmf_fields[key]
|
|
lxmf_message.fields = lxmf_fields
|
|
attachments_stripped = True
|
|
print(
|
|
f"Stripped attachments from stranger: {source_hash}",
|
|
)
|
|
|
|
# upsert lxmf message to database with spam flag
|
|
self.db_upsert_lxmf_message(
|
|
lxmf_message,
|
|
is_spam=is_spam,
|
|
attachments_stripped=attachments_stripped,
|
|
context=ctx,
|
|
)
|
|
self._maybe_store_path_at_send_for_lxmf(ctx, lxmf_message)
|
|
|
|
# handle forwarding
|
|
self.handle_forwarding(lxmf_message, context=ctx)
|
|
|
|
self._apply_lxmf_sieve_folder_rule(
|
|
source_hash,
|
|
context=ctx,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
self._apply_lxmf_sieve_banish_rule(
|
|
source_hash,
|
|
context=ctx,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
self._apply_message_blocklist_banish_rule(
|
|
source_hash,
|
|
context=ctx,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
|
|
# handle telemetry
|
|
try:
|
|
message_fields = lxmf_message.get_fields()
|
|
|
|
# Single telemetry entry
|
|
if LXMF.FIELD_TELEMETRY in message_fields:
|
|
self.process_incoming_telemetry(
|
|
source_hash,
|
|
message_fields[LXMF.FIELD_TELEMETRY],
|
|
lxmf_message,
|
|
context=ctx,
|
|
)
|
|
|
|
# Telemetry stream (multiple entries)
|
|
if (
|
|
hasattr(LXMF, "FIELD_TELEMETRY_STREAM")
|
|
and LXMF.FIELD_TELEMETRY_STREAM in message_fields
|
|
):
|
|
stream = message_fields[LXMF.FIELD_TELEMETRY_STREAM]
|
|
if isinstance(stream, (list, tuple)):
|
|
for entry in stream:
|
|
if isinstance(entry, (list, tuple)) and len(entry) >= 3:
|
|
entry_source = (
|
|
entry[0].hex()
|
|
if isinstance(entry[0], bytes)
|
|
else entry[0]
|
|
)
|
|
entry_timestamp = entry[1]
|
|
entry_data = entry[2]
|
|
self.process_incoming_telemetry(
|
|
entry_source,
|
|
entry_data,
|
|
lxmf_message,
|
|
timestamp_override=entry_timestamp,
|
|
context=ctx,
|
|
)
|
|
except Exception as e:
|
|
print(f"Failed to handle telemetry in LXMF message: {e}")
|
|
|
|
# update lxmf user icon if icon appearance field is available
|
|
try:
|
|
message_fields = lxmf_message.get_fields()
|
|
if LXMF.FIELD_ICON_APPEARANCE in message_fields:
|
|
icon_appearance = message_fields[LXMF.FIELD_ICON_APPEARANCE]
|
|
icon_name = icon_appearance[0]
|
|
foreground_colour = "#" + icon_appearance[1].hex()
|
|
background_colour = "#" + icon_appearance[2].hex()
|
|
|
|
local_hash = (
|
|
ctx.local_lxmf_destination.hexhash
|
|
if ctx.local_lxmf_destination
|
|
else None
|
|
)
|
|
source_hash = lxmf_message.source_hash.hex()
|
|
|
|
# ignore our own icon and empty payloads to avoid overwriting peers with our appearance
|
|
if (source_hash and local_hash and source_hash == local_hash) or (
|
|
not icon_name or not foreground_colour or not background_colour
|
|
):
|
|
pass
|
|
else:
|
|
local_icon_name = ctx.config.lxmf_user_icon_name.get()
|
|
local_icon_fg = (
|
|
ctx.config.lxmf_user_icon_foreground_colour.get()
|
|
)
|
|
local_icon_bg = (
|
|
ctx.config.lxmf_user_icon_background_colour.get()
|
|
)
|
|
|
|
# if incoming icon matches our own, skip storing and clear any mistaken stored copy
|
|
# for now, but this will need to be updated later if two users do have the same icon
|
|
if (
|
|
local_icon_name
|
|
and local_icon_fg
|
|
and local_icon_bg
|
|
and icon_name == local_icon_name
|
|
and foreground_colour == local_icon_fg
|
|
and background_colour == local_icon_bg
|
|
):
|
|
ctx.database.misc.delete_user_icon(source_hash)
|
|
else:
|
|
self.update_lxmf_user_icon(
|
|
source_hash,
|
|
icon_name,
|
|
foreground_colour,
|
|
background_colour,
|
|
context=ctx,
|
|
)
|
|
except Exception as e:
|
|
print("failed to update lxmf user icon from lxmf message")
|
|
print(e)
|
|
|
|
sender_name = ctx.database.announces.get_custom_display_name(source_hash)
|
|
if not sender_name:
|
|
announce = ctx.database.announces.get_announce_by_hash(source_hash)
|
|
if announce and announce["app_data"]:
|
|
sender_name = parse_lxmf_display_name(
|
|
app_data_base64=announce["app_data"],
|
|
default_value=None,
|
|
)
|
|
|
|
if not sender_name:
|
|
sender_name = source_hash[:8]
|
|
|
|
msg_dict = convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
)
|
|
self._merge_stored_path_fields_from_db(
|
|
ctx,
|
|
lxmf_message.hash.hex(),
|
|
msg_dict,
|
|
)
|
|
|
|
suppress_notifications = self._lxmf_sieve_suppresses_notifications(
|
|
source_hash,
|
|
context=ctx,
|
|
message_title=message_title,
|
|
message_content=message_content,
|
|
)
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf.delivery",
|
|
"remote_identity_name": sender_name,
|
|
"lxmf_message": msg_dict,
|
|
"sieve_suppress_notifications": suppress_notifications,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
except Exception as e:
|
|
# do nothing on error
|
|
print(f"lxmf_delivery error: {e}")
|
|
|
|
# handles lxmf message forwarding logic
|
|
def handle_forwarding(self, lxmf_message: LXMF.LXMessage, context=None):
|
|
try:
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
source_hash = lxmf_message.source_hash.hex()
|
|
destination_hash = lxmf_message.destination_hash.hex()
|
|
|
|
# extract fields for potential forwarding
|
|
lxmf_fields = lxmf_message.get_fields()
|
|
image_field = None
|
|
audio_field = None
|
|
file_attachments_field = None
|
|
|
|
if LXMF.FIELD_IMAGE in lxmf_fields:
|
|
val = lxmf_fields[LXMF.FIELD_IMAGE]
|
|
image_field = LxmfImageField(val[0], val[1])
|
|
|
|
if LXMF.FIELD_AUDIO in lxmf_fields:
|
|
val = lxmf_fields[LXMF.FIELD_AUDIO]
|
|
audio_field = LxmfAudioField(val[0], val[1])
|
|
|
|
if LXMF.FIELD_FILE_ATTACHMENTS in lxmf_fields:
|
|
attachments = [
|
|
LxmfFileAttachment(val[0], val[1])
|
|
for val in lxmf_fields[LXMF.FIELD_FILE_ATTACHMENTS]
|
|
]
|
|
file_attachments_field = LxmfFileAttachmentsField(attachments)
|
|
|
|
app_extensions = None
|
|
if LXMF_APP_EXTENSIONS_FIELD in lxmf_fields and isinstance(
|
|
lxmf_fields[LXMF_APP_EXTENSIONS_FIELD],
|
|
dict,
|
|
):
|
|
app_extensions = lxmf_fields[LXMF_APP_EXTENSIONS_FIELD]
|
|
|
|
# check if this message is for an alias identity (REPLY PATH)
|
|
mapping = ctx.database.messages.get_forwarding_mapping(
|
|
alias_hash=destination_hash,
|
|
)
|
|
|
|
if mapping:
|
|
# this is a reply from User C to User B (alias). Forward to User A.
|
|
print(
|
|
f"Forwarding reply from {source_hash} back to original sender {mapping['original_sender_hash']}",
|
|
)
|
|
AsyncUtils.run_async(
|
|
self.send_message(
|
|
destination_hash=mapping["original_sender_hash"],
|
|
content=lxmf_message.content,
|
|
title=lxmf_message.title
|
|
if hasattr(lxmf_message, "title")
|
|
else "",
|
|
image_field=image_field,
|
|
audio_field=audio_field,
|
|
file_attachments_field=file_attachments_field,
|
|
app_extensions=app_extensions,
|
|
context=ctx,
|
|
),
|
|
)
|
|
return
|
|
|
|
# check if this message matches a forwarding rule (FORWARD PATH)
|
|
# we check for rules that apply to the destination of this message
|
|
rules = ctx.database.misc.get_forwarding_rules(
|
|
identity_hash=destination_hash,
|
|
active_only=True,
|
|
)
|
|
|
|
for rule in rules:
|
|
# check source filter if set
|
|
if (
|
|
rule["source_filter_hash"]
|
|
and rule["source_filter_hash"] != source_hash
|
|
):
|
|
continue
|
|
|
|
# find or create mapping for this (Source, Final Recipient) pair
|
|
mapping = ctx.forwarding_manager.get_or_create_mapping(
|
|
source_hash,
|
|
rule["forward_to_hash"],
|
|
destination_hash,
|
|
)
|
|
|
|
# forward to User C from Alias Identity
|
|
print(
|
|
f"Forwarding message from {source_hash} to {rule['forward_to_hash']} via alias {mapping['alias_hash']}",
|
|
)
|
|
AsyncUtils.run_async(
|
|
self.send_message(
|
|
destination_hash=rule["forward_to_hash"],
|
|
content=lxmf_message.content,
|
|
title=lxmf_message.title
|
|
if hasattr(lxmf_message, "title")
|
|
else "",
|
|
sender_identity_hash=mapping["alias_hash"],
|
|
image_field=image_field,
|
|
audio_field=audio_field,
|
|
file_attachments_field=file_attachments_field,
|
|
app_extensions=app_extensions,
|
|
context=ctx,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"Error in handle_forwarding: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
|
|
def _merge_stored_path_fields_from_db(self, ctx, msg_hash_hex, msg_dict):
|
|
try:
|
|
row = ctx.database.messages.get_lxmf_message_by_hash(msg_hash_hex)
|
|
if not row:
|
|
return
|
|
|
|
def _scalar_row_value(key):
|
|
try:
|
|
if hasattr(row, "get"):
|
|
v = row.get(key)
|
|
elif hasattr(row, "keys") and key in row.keys():
|
|
v = row[key]
|
|
else:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
if isinstance(v, (bool, int, float, str)):
|
|
return v
|
|
return None
|
|
|
|
hops = _scalar_row_value("path_hops_at_send")
|
|
if hops is not None:
|
|
msg_dict["path_hops_at_send"] = hops
|
|
iface = _scalar_row_value("path_interface_at_send")
|
|
if iface is not None:
|
|
msg_dict["path_interface_at_send"] = iface
|
|
pfm = _scalar_row_value("path_finding_measure")
|
|
if pfm is not None:
|
|
msg_dict["path_finding_measure"] = pfm
|
|
prh = _scalar_row_value("path_row_hash_hex")
|
|
if prh is not None:
|
|
msg_dict["path_row_hash_hex"] = prh
|
|
except Exception:
|
|
pass
|
|
|
|
def _reticulum_path_hops_and_interface_to_identity(self, ctx, identity_hash_bytes):
|
|
if not identity_hash_bytes:
|
|
return None, None
|
|
try:
|
|
destination_hash = (
|
|
identity_hash_bytes
|
|
if isinstance(identity_hash_bytes, (bytes, bytearray))
|
|
else bytes.fromhex(str(identity_hash_bytes))
|
|
)
|
|
except Exception:
|
|
return None, None
|
|
destination_hash_hex = destination_hash.hex()
|
|
local_hashes: set[str] = set()
|
|
with contextlib.suppress(Exception):
|
|
if ctx and ctx.identity:
|
|
local_hashes.add(ctx.identity.hash.hex())
|
|
with contextlib.suppress(Exception):
|
|
if self.local_lxmf_destination is not None:
|
|
local_hashes.add(self.local_lxmf_destination.hash.hex())
|
|
with contextlib.suppress(Exception):
|
|
if ctx and ctx.message_router:
|
|
pdest = ctx.message_router.propagation_destination
|
|
if pdest is not None and getattr(pdest, "hash", None):
|
|
local_hashes.add(pdest.hash.hex())
|
|
|
|
if destination_hash_hex in local_hashes:
|
|
return 0, "Local"
|
|
if not RNS.Transport.has_path(destination_hash):
|
|
return None, None
|
|
hops = RNS.Transport.hops_to(destination_hash)
|
|
next_hop_bytes = None
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
next_hop_bytes = self.reticulum.get_next_hop(destination_hash)
|
|
if next_hop_bytes is None:
|
|
return None, None
|
|
iface = (
|
|
self.reticulum.get_next_hop_if_name(destination_hash)
|
|
if hasattr(self, "reticulum") and self.reticulum
|
|
else None
|
|
)
|
|
return hops, iface
|
|
|
|
def _maybe_store_path_at_send_for_lxmf(self, ctx, lxmf_message):
|
|
try:
|
|
msg_hash = lxmf_message.hash.hex()
|
|
row = ctx.database.messages.get_lxmf_message_by_hash(msg_hash)
|
|
if not row or row.get("path_hops_at_send") is not None:
|
|
return
|
|
if getattr(lxmf_message, "incoming", False):
|
|
dest_bytes = lxmf_message.source_hash
|
|
else:
|
|
dest_bytes = lxmf_message.destination_hash
|
|
hops, iface = self._reticulum_path_hops_and_interface_to_identity(
|
|
ctx,
|
|
dest_bytes,
|
|
)
|
|
if hops is None:
|
|
return
|
|
ctx.database.messages.set_lxmf_message_path_at_send_if_unset(
|
|
msg_hash,
|
|
hops,
|
|
iface,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def on_lxmf_sending_state_updated(self, lxmf_message, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.database:
|
|
return
|
|
|
|
progress_pct = round(lxmf_message.progress * 100, 2)
|
|
rssi = lxmf_message.rssi
|
|
snr = lxmf_message.snr
|
|
quality = lxmf_message.q
|
|
if self.reticulum:
|
|
if rssi is None:
|
|
rssi = self.reticulum.get_packet_rssi(lxmf_message.hash)
|
|
if snr is None:
|
|
snr = self.reticulum.get_packet_snr(lxmf_message.hash)
|
|
if quality is None:
|
|
quality = self.reticulum.get_packet_q(lxmf_message.hash)
|
|
|
|
ctx.database.messages.update_lxmf_message_state(
|
|
message_hash=lxmf_message.hash.hex(),
|
|
state=convert_lxmf_state_to_string(lxmf_message),
|
|
progress=progress_pct,
|
|
delivery_attempts=lxmf_message.delivery_attempts,
|
|
next_delivery_attempt_at=getattr(
|
|
lxmf_message,
|
|
"next_delivery_attempt",
|
|
None,
|
|
),
|
|
rssi=rssi,
|
|
snr=snr,
|
|
quality=quality,
|
|
method=convert_lxmf_method_to_string(lxmf_message),
|
|
)
|
|
self._maybe_store_path_at_send_for_lxmf(ctx, lxmf_message)
|
|
|
|
msg_dict = convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
message_router=ctx.message_router,
|
|
)
|
|
self._merge_stored_path_fields_from_db(ctx, lxmf_message.hash.hex(), msg_dict)
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf_message_state_updated",
|
|
"lxmf_message": msg_dict,
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# handle delivery failed for an outbound lxmf message
|
|
def on_lxmf_sending_failed(self, lxmf_message, context=None):
|
|
ctx = context if context is not None else self.current_context
|
|
# check if this failed message should fall back to sending via a propagation node
|
|
if (
|
|
lxmf_message.state == LXMF.LXMessage.FAILED
|
|
and hasattr(lxmf_message, "try_propagation_on_fail")
|
|
and lxmf_message.try_propagation_on_fail
|
|
):
|
|
self.send_failed_message_via_propagation_node(lxmf_message, context=ctx)
|
|
|
|
# update state
|
|
self.on_lxmf_sending_state_updated(lxmf_message, context=ctx)
|
|
|
|
# sends a previously failed message via a propagation node
|
|
def send_failed_message_via_propagation_node(
|
|
self,
|
|
lxmf_message: LXMF.LXMessage,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# reset internal message state
|
|
lxmf_message.packed = None
|
|
lxmf_message.delivery_attempts = 0
|
|
if hasattr(lxmf_message, "next_delivery_attempt"):
|
|
del lxmf_message.next_delivery_attempt
|
|
|
|
# this message should now be sent via a propagation node
|
|
lxmf_message.desired_method = LXMF.LXMessage.PROPAGATED
|
|
lxmf_message.try_propagation_on_fail = False
|
|
|
|
# resend message
|
|
source_hash = lxmf_message.source_hash.hex()
|
|
router = ctx.message_router
|
|
if (
|
|
ctx.forwarding_manager
|
|
and source_hash in ctx.forwarding_manager.forwarding_routers
|
|
):
|
|
router = ctx.forwarding_manager.forwarding_routers[source_hash]
|
|
router.handle_outbound(lxmf_message)
|
|
|
|
# upserts the provided lxmf message to the database
|
|
def _is_self_lxmf_destination(self, destination_hash: str, context=None) -> bool:
|
|
"""True when destination_hash refers to this identity's own LXMF peer."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.local_lxmf_destination:
|
|
return False
|
|
norm_dest = normalize_hex_identifier(destination_hash)
|
|
if not norm_dest:
|
|
return False
|
|
local_lxmf = normalize_hex_identifier(ctx.local_lxmf_destination.hexhash)
|
|
if norm_dest == local_lxmf:
|
|
return True
|
|
if ctx.identity:
|
|
local_id = normalize_hex_identifier(ctx.identity.hash.hex())
|
|
if norm_dest == local_id:
|
|
return True
|
|
try:
|
|
resolved = self.get_lxmf_destination_hash_for_identity_hash(norm_dest)
|
|
if resolved and normalize_hex_identifier(resolved) == local_lxmf:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def db_upsert_lxmf_message(
|
|
self,
|
|
lxmf_message: LXMF.LXMessage,
|
|
is_spam: bool = False,
|
|
attachments_stripped: bool = False,
|
|
context=None,
|
|
path_finding_measure: str | None = None,
|
|
path_row_hash_hex: str | None = None,
|
|
state_override: str | None = None,
|
|
method_override: str | None = None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
# convert lxmf message to dict
|
|
lxmf_message_dict = convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
reticulum=self.reticulum,
|
|
message_router=ctx.message_router,
|
|
)
|
|
if state_override is not None:
|
|
lxmf_message_dict["state"] = state_override
|
|
if method_override is not None:
|
|
lxmf_message_dict["method"] = method_override
|
|
lxmf_message_dict["is_spam"] = 1 if is_spam else 0
|
|
lxmf_message_dict["attachments_stripped"] = 1 if attachments_stripped else 0
|
|
if path_finding_measure is not None:
|
|
lxmf_message_dict["path_finding_measure"] = path_finding_measure
|
|
if path_row_hash_hex is not None:
|
|
lxmf_message_dict["path_row_hash_hex"] = path_row_hash_hex
|
|
|
|
# calculate peer hash
|
|
local_hash = ctx.local_lxmf_destination.hexhash
|
|
if lxmf_message_dict["source_hash"] == local_hash:
|
|
lxmf_message_dict["peer_hash"] = lxmf_message_dict["destination_hash"]
|
|
else:
|
|
lxmf_message_dict["peer_hash"] = lxmf_message_dict["source_hash"]
|
|
|
|
try:
|
|
ctx.database.messages.upsert_lxmf_message(lxmf_message_dict)
|
|
except Exception:
|
|
message_hash = getattr(lxmf_message, "hash", None)
|
|
hash_label = message_hash.hex() if message_hash is not None else "unknown"
|
|
logger.exception(
|
|
"Failed to persist inbound LXMF message %s from %s",
|
|
hash_label,
|
|
lxmf_message_dict.get("source_hash", "unknown"),
|
|
)
|
|
raise
|
|
|
|
def _lxmf_path_wait_seconds(self):
|
|
return reticulum_pathfinding.lxmf_path_wait_cap_seconds()
|
|
|
|
async def _await_transport_path(self, destination_hash_bytes: bytes):
|
|
r = self.reticulum if hasattr(self, "reticulum") else None
|
|
return await reticulum_pathfinding.await_transport_path_for_outbound_lxmf(
|
|
r,
|
|
destination_hash_bytes,
|
|
)
|
|
|
|
# upserts the provided announce to the database
|
|
# handle sending an lxmf message to reticulum
|
|
async def send_message(
|
|
self,
|
|
destination_hash: str,
|
|
content: str,
|
|
image_field: LxmfImageField = None,
|
|
audio_field: LxmfAudioField = None,
|
|
file_attachments_field: LxmfFileAttachmentsField = None,
|
|
telemetry_data: bytes | None = None,
|
|
commands: list | None = None,
|
|
delivery_method: str | None = None,
|
|
title: str = "",
|
|
sender_identity_hash: str | None = None,
|
|
reply_to_hash: str | None = None,
|
|
reply_quoted_content: str | None = None,
|
|
reaction_to_hash: str | None = None,
|
|
reaction_emoji: str | None = None,
|
|
app_extensions: dict | None = None,
|
|
no_display: bool = False,
|
|
context=None,
|
|
) -> LXMF.LXMessage:
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
raise RuntimeError("No identity context available for sending message")
|
|
|
|
if isinstance(content, bytes):
|
|
content_str = content.decode("utf-8", errors="replace")
|
|
else:
|
|
content_str = content or ""
|
|
quoted_str = reply_quoted_content or ""
|
|
has_standard_reaction = (
|
|
reaction_to_hash is not None and reaction_emoji is not None
|
|
)
|
|
is_reaction_only = bool(
|
|
has_standard_reaction
|
|
and not (content_str and content_str.strip())
|
|
and image_field is None
|
|
and audio_field is None
|
|
and file_attachments_field is None
|
|
and telemetry_data is None
|
|
and commands is None
|
|
and reply_to_hash is None
|
|
and not (quoted_str and quoted_str.strip()),
|
|
)
|
|
|
|
# convert destination hash to bytes
|
|
try:
|
|
destination_hash_bytes = bytes.fromhex(destination_hash)
|
|
except (TypeError, ValueError) as exc:
|
|
msg = "Invalid destination hash."
|
|
raise ValueError(msg) from exc
|
|
|
|
wants_propagated = delivery_method == "propagated"
|
|
is_local_self = self._is_self_lxmf_destination(destination_hash, ctx)
|
|
if is_local_self and wants_propagated:
|
|
msg = "Propagated delivery is not available for messages to yourself."
|
|
raise ValueError(msg)
|
|
|
|
destination_identity = self.recall_identity(destination_hash)
|
|
if destination_identity is None and is_local_self and ctx.identity:
|
|
destination_identity = ctx.identity
|
|
if destination_identity is None:
|
|
msg = (
|
|
"Could not recall destination identity. "
|
|
"Wait for an announce from this peer, then try again."
|
|
)
|
|
raise LookupError(msg)
|
|
|
|
delivery_hash_bytes = reticulum_pathfinding.lxmf_delivery_hash_bytes(
|
|
destination_identity,
|
|
destination_hash_bytes,
|
|
)
|
|
|
|
# Direct/opportunistic need a live peer path. Propagated uses the
|
|
# propagation node, so skip the peer path wait (still record outcome).
|
|
if is_local_self:
|
|
path_outcome = reticulum_pathfinding.OutboundPathOutcome(
|
|
True,
|
|
"local_self",
|
|
False,
|
|
)
|
|
elif wants_propagated:
|
|
path_outcome = reticulum_pathfinding.OutboundPathOutcome(
|
|
False,
|
|
"skipped_for_propagated",
|
|
False,
|
|
)
|
|
else:
|
|
# Reticulum keeps a live path table, and entries expire when peers move or links drop.
|
|
# We cannot replay "old" paths from the app layer. Transport.request_path refreshes discovery.
|
|
# Wait on lxmf.delivery, not an identity hash or some other aspect dest.
|
|
path_outcome = await self._await_transport_path(delivery_hash_bytes)
|
|
|
|
# Direct/opportunistic delivery needs a live transport path. Propagated
|
|
# delivery can proceed without a peer path (it uses the propagation node).
|
|
if (
|
|
not is_local_self
|
|
and not wants_propagated
|
|
and not path_outcome.path_available
|
|
):
|
|
msg = (
|
|
"No path to destination. "
|
|
"Use Path Finder or wait for a route, then try again."
|
|
)
|
|
raise TimeoutError(msg)
|
|
|
|
# create destination for recipients lxmf delivery address
|
|
lxmf_destination = RNS.Destination(
|
|
destination_identity,
|
|
RNS.Destination.OUT,
|
|
RNS.Destination.SINGLE,
|
|
"lxmf",
|
|
"delivery",
|
|
)
|
|
|
|
# determine how the user wants to send the message
|
|
desired_delivery_method = None
|
|
if delivery_method == "direct":
|
|
desired_delivery_method = LXMF.LXMessage.DIRECT
|
|
elif delivery_method == "opportunistic":
|
|
desired_delivery_method = LXMF.LXMessage.OPPORTUNISTIC
|
|
elif delivery_method == "propagated":
|
|
desired_delivery_method = LXMF.LXMessage.PROPAGATED
|
|
|
|
# determine how to send the message if the user didn't provide a method
|
|
if desired_delivery_method is None:
|
|
# send messages over a direct link by default
|
|
desired_delivery_method = LXMF.LXMessage.DIRECT
|
|
if (
|
|
not ctx.message_router.delivery_link_available(delivery_hash_bytes)
|
|
and RNS.Identity.current_ratchet_id(delivery_hash_bytes) is not None
|
|
):
|
|
# since there's no link established to the destination, it's faster to send opportunistically
|
|
# this is because it takes several packets to establish a link, and then we still have to send the message over it
|
|
# oppotunistic mode will send the message in a single packet (if the message is small enough, otherwise it falls back to a direct link)
|
|
# we will only do this if an encryption ratchet is available, so single packet delivery is more secure
|
|
desired_delivery_method = LXMF.LXMessage.OPPORTUNISTIC
|
|
|
|
# determine which identity to send from
|
|
source_destination = ctx.local_lxmf_destination
|
|
if sender_identity_hash is not None:
|
|
if (
|
|
ctx.forwarding_manager
|
|
and sender_identity_hash
|
|
in ctx.forwarding_manager.forwarding_destinations
|
|
):
|
|
source_destination = ctx.forwarding_manager.forwarding_destinations[
|
|
sender_identity_hash
|
|
]
|
|
else:
|
|
print(
|
|
f"Warning: requested sender identity {sender_identity_hash} not found, using default.",
|
|
)
|
|
|
|
# create lxmf message
|
|
lxmf_message = LXMF.LXMessage(
|
|
lxmf_destination,
|
|
source_destination,
|
|
content,
|
|
title=title,
|
|
desired_method=desired_delivery_method,
|
|
)
|
|
lxmf_message.try_propagation_on_fail = (
|
|
ctx.config.auto_send_failed_messages_to_propagation_node.get()
|
|
)
|
|
|
|
lxmf_message.fields = {}
|
|
|
|
if not is_reaction_only:
|
|
lxmf_message.fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
|
|
|
|
if self._is_contact(destination_hash, context=ctx) and not is_reaction_only:
|
|
lxmf_message.include_ticket = True
|
|
|
|
# add file attachments field
|
|
if file_attachments_field is not None:
|
|
# create array of [[file_name, file_bytes], [file_name, file_bytes], ...]
|
|
file_attachments = [
|
|
[file_attachment.file_name, file_attachment.file_bytes]
|
|
for file_attachment in file_attachments_field.file_attachments
|
|
]
|
|
|
|
# set field attachments field
|
|
lxmf_message.fields[LXMF.FIELD_FILE_ATTACHMENTS] = file_attachments
|
|
|
|
# add image field
|
|
if image_field is not None:
|
|
lxmf_message.fields[LXMF.FIELD_IMAGE] = [
|
|
image_field.image_type,
|
|
image_field.image_bytes,
|
|
]
|
|
|
|
# add audio field
|
|
if audio_field is not None:
|
|
audio_bytes = audio_field.audio_bytes
|
|
if audio_field.audio_mode == LXMF.AM_OPUS_OGG:
|
|
audio_bytes = self._convert_webm_opus_to_ogg(audio_bytes)
|
|
lxmf_message.fields[LXMF.FIELD_AUDIO] = [
|
|
audio_field.audio_mode,
|
|
audio_bytes,
|
|
]
|
|
|
|
# add telemetry field
|
|
if telemetry_data is not None:
|
|
lxmf_message.fields[LXMF.FIELD_TELEMETRY] = telemetry_data
|
|
|
|
# add commands field
|
|
if commands is not None:
|
|
lxmf_message.fields[LXMF.FIELD_COMMANDS] = commands
|
|
|
|
if reply_to_hash is not None:
|
|
lxmf_message.fields[FIELD_REPLY_TO] = bytes.fromhex(reply_to_hash)
|
|
if reply_quoted_content is not None and reply_quoted_content:
|
|
lxmf_message.fields[FIELD_REPLY_QUOTE] = reply_quoted_content.encode(
|
|
"utf-8",
|
|
)
|
|
|
|
if has_standard_reaction:
|
|
lxmf_message.fields[FIELD_REACTION] = build_lxmf_reaction_field(
|
|
reaction_to_hash,
|
|
reaction_emoji or "",
|
|
)
|
|
elif app_extensions is not None:
|
|
lxmf_message.fields[LXMF_APP_EXTENSIONS_FIELD] = app_extensions
|
|
|
|
# add icon appearance if configured and not already sent to this destination
|
|
current_icon_hash = self.get_current_icon_hash()
|
|
if current_icon_hash is not None and not is_reaction_only:
|
|
last_sent_icon_hash = self.database.misc.get_last_sent_icon_hash(
|
|
destination_hash,
|
|
)
|
|
|
|
if last_sent_icon_hash != current_icon_hash:
|
|
lxmf_user_icon_name = self.config.lxmf_user_icon_name.get()
|
|
lxmf_user_icon_foreground_colour = (
|
|
self.config.lxmf_user_icon_foreground_colour.get()
|
|
)
|
|
lxmf_user_icon_background_colour = (
|
|
self.config.lxmf_user_icon_background_colour.get()
|
|
)
|
|
|
|
lxmf_message.fields[LXMF.FIELD_ICON_APPEARANCE] = [
|
|
lxmf_user_icon_name,
|
|
ColourUtils.hex_colour_to_byte_array(
|
|
lxmf_user_icon_foreground_colour,
|
|
),
|
|
ColourUtils.hex_colour_to_byte_array(
|
|
lxmf_user_icon_background_colour,
|
|
),
|
|
]
|
|
|
|
# update last sent icon hash for this destination
|
|
ctx.database.misc.update_last_sent_icon_hash(
|
|
destination_hash,
|
|
current_icon_hash,
|
|
)
|
|
|
|
if is_local_self:
|
|
lxmf_message.pack()
|
|
lxmf_message.state = LXMF.LXMessage.DELIVERED
|
|
lxmf_message.progress = 1.0
|
|
local_peer = ctx.local_lxmf_destination.hexhash
|
|
if not no_display:
|
|
self.db_upsert_lxmf_message(
|
|
lxmf_message,
|
|
context=ctx,
|
|
path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure(
|
|
path_outcome,
|
|
),
|
|
path_row_hash_hex=local_peer,
|
|
state_override="delivered",
|
|
method_override="local",
|
|
)
|
|
ws_payload = convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
message_router=ctx.message_router,
|
|
)
|
|
ws_payload["state"] = "delivered"
|
|
ws_payload["method"] = "local"
|
|
ws_payload["progress"] = 100.0
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf_message_created",
|
|
"lxmf_message": ws_payload,
|
|
},
|
|
),
|
|
)
|
|
return lxmf_message
|
|
|
|
# register delivery callbacks
|
|
lxmf_message.register_delivery_callback(
|
|
lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx),
|
|
)
|
|
lxmf_message.register_failed_callback(
|
|
lambda msg: self.on_lxmf_sending_failed(msg, context=ctx),
|
|
)
|
|
|
|
# determine which router to use
|
|
router = ctx.message_router
|
|
if (
|
|
sender_identity_hash is not None
|
|
and ctx.forwarding_manager
|
|
and sender_identity_hash in ctx.forwarding_manager.forwarding_routers
|
|
):
|
|
router = ctx.forwarding_manager.forwarding_routers[sender_identity_hash]
|
|
|
|
# send lxmf message to be routed to destination
|
|
router.handle_outbound(lxmf_message)
|
|
|
|
# upsert lxmf message to database
|
|
if not no_display:
|
|
self.db_upsert_lxmf_message(
|
|
lxmf_message,
|
|
context=ctx,
|
|
path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure(
|
|
path_outcome,
|
|
),
|
|
path_row_hash_hex=delivery_hash_bytes.hex()
|
|
if path_outcome.path_available
|
|
else None,
|
|
)
|
|
|
|
# tell all websocket clients that old failed message was deleted so it can remove from ui
|
|
if not no_display:
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf_message_created",
|
|
"lxmf_message": convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
message_router=ctx.message_router,
|
|
),
|
|
},
|
|
),
|
|
)
|
|
|
|
# handle lxmf message progress loop without blocking or awaiting
|
|
# otherwise other incoming websocket packets will not be processed until sending is complete
|
|
# which results in the next message not showing up until the first message is finished
|
|
if not no_display:
|
|
AsyncUtils.run_async(
|
|
self.handle_lxmf_message_progress(lxmf_message, context=ctx),
|
|
)
|
|
|
|
return lxmf_message
|
|
|
|
async def send_reaction(
|
|
self,
|
|
destination_hash: str,
|
|
target_message_hash: str,
|
|
emoji: str,
|
|
context=None,
|
|
) -> LXMF.LXMessage:
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
raise RuntimeError("No identity context available for sending reaction")
|
|
return await self.send_message(
|
|
destination_hash=destination_hash,
|
|
content="",
|
|
delivery_method="opportunistic",
|
|
reaction_to_hash=target_message_hash,
|
|
reaction_emoji=emoji,
|
|
context=context,
|
|
)
|
|
|
|
# get hash of current icon appearance configuration
|
|
def get_current_icon_hash(self, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return None
|
|
|
|
name = ctx.config.lxmf_user_icon_name.get()
|
|
fg = ctx.config.lxmf_user_icon_foreground_colour.get()
|
|
bg = ctx.config.lxmf_user_icon_background_colour.get()
|
|
|
|
if not all([name, fg, bg]):
|
|
return None
|
|
|
|
data = f"{name}|{fg}|{bg}"
|
|
return hashlib.sha256(data.encode()).hexdigest()
|
|
|
|
def process_incoming_telemetry(
|
|
self,
|
|
source_hash,
|
|
telemetry_data,
|
|
lxmf_message,
|
|
timestamp_override=None,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
try:
|
|
unpacked = Telemeter.from_packed(telemetry_data)
|
|
if unpacked:
|
|
timestamp = timestamp_override or (
|
|
unpacked["time"]["utc"] if "time" in unpacked else int(time.time())
|
|
)
|
|
|
|
# physical link info
|
|
physical_link = {
|
|
"rssi": self.reticulum.get_packet_rssi(lxmf_message.hash)
|
|
if hasattr(self, "reticulum") and self.reticulum
|
|
else None,
|
|
"snr": self.reticulum.get_packet_snr(lxmf_message.hash)
|
|
if hasattr(self, "reticulum") and self.reticulum
|
|
else None,
|
|
"q": self.reticulum.get_packet_q(lxmf_message.hash)
|
|
if hasattr(self, "reticulum") and self.reticulum
|
|
else None,
|
|
}
|
|
|
|
ctx.database.telemetry.upsert_telemetry(
|
|
destination_hash=source_hash,
|
|
timestamp=timestamp,
|
|
data=telemetry_data,
|
|
received_from=ctx.local_lxmf_destination.hexhash,
|
|
physical_link=physical_link,
|
|
)
|
|
|
|
# broadcast telemetry update via websocket
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf.telemetry",
|
|
"destination_hash": source_hash,
|
|
"timestamp": timestamp,
|
|
"telemetry": unpacked,
|
|
"physical_link": physical_link,
|
|
"is_tracking": ctx.database.telemetry.is_tracking(
|
|
source_hash,
|
|
),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"Error processing incoming telemetry: {e}")
|
|
|
|
def _resolve_location_for_telemetry(self):
|
|
location_source = self.config.location_source.get()
|
|
if location_source == "disabled":
|
|
return None, None
|
|
if location_source == "manual":
|
|
lat = self.config.location_manual_lat.get()
|
|
lon = self.config.location_manual_lon.get()
|
|
else:
|
|
lat = self.database.config.get("map_default_lat")
|
|
lon = self.database.config.get("map_default_lon")
|
|
return lat, lon
|
|
|
|
def handle_telemetry_request(self, to_addr_hash: str):
|
|
lat, lon = self._resolve_location_for_telemetry()
|
|
|
|
if lat is None or lon is None:
|
|
print(
|
|
f"Cannot respond to telemetry request from {to_addr_hash}: No location set",
|
|
)
|
|
return
|
|
|
|
try:
|
|
location = {
|
|
"latitude": float(lat),
|
|
"longitude": float(lon),
|
|
"altitude": 0,
|
|
"speed": 0,
|
|
"bearing": 0,
|
|
"accuracy": 0,
|
|
"last_update": int(time.time()),
|
|
}
|
|
|
|
telemetry_data = Telemeter.pack(location=location)
|
|
|
|
AsyncUtils.run_async(
|
|
self.send_message(
|
|
destination_hash=to_addr_hash,
|
|
content="",
|
|
telemetry_data=telemetry_data,
|
|
delivery_method="opportunistic",
|
|
no_display=False,
|
|
),
|
|
)
|
|
except Exception as e:
|
|
print(f"Failed to respond to telemetry request: {e}")
|
|
|
|
# updates lxmf message in database and broadcasts to websocket until it's delivered, or it fails
|
|
async def handle_lxmf_message_progress(self, lxmf_message, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
should_update_message = True
|
|
while should_update_message:
|
|
progress_pct = round(lxmf_message.progress * 100, 2)
|
|
ctx.database.messages.update_lxmf_message_state(
|
|
message_hash=lxmf_message.hash.hex(),
|
|
state=convert_lxmf_state_to_string(lxmf_message),
|
|
progress=progress_pct,
|
|
delivery_attempts=lxmf_message.delivery_attempts,
|
|
next_delivery_attempt_at=getattr(
|
|
lxmf_message,
|
|
"next_delivery_attempt",
|
|
None,
|
|
),
|
|
method=convert_lxmf_method_to_string(lxmf_message),
|
|
)
|
|
self._maybe_store_path_at_send_for_lxmf(ctx, lxmf_message)
|
|
|
|
msg_dict = convert_lxmf_message_to_dict(
|
|
lxmf_message,
|
|
include_attachments=False,
|
|
reticulum=self.reticulum,
|
|
message_router=ctx.message_router,
|
|
)
|
|
self._merge_stored_path_fields_from_db(
|
|
ctx,
|
|
lxmf_message.hash.hex(),
|
|
msg_dict,
|
|
)
|
|
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf_message_state_updated",
|
|
"lxmf_message": msg_dict,
|
|
},
|
|
),
|
|
)
|
|
|
|
# check if we should stop updating
|
|
if is_lxmf_outbound_progress_terminal(lxmf_message):
|
|
should_update_message = False
|
|
else:
|
|
await asyncio.sleep(1)
|
|
|
|
def _note_announce_timestamp(self) -> None:
|
|
now = time.time()
|
|
self.announce_timestamps.append(now)
|
|
self.announce_timestamps = prune_announce_timestamps(
|
|
self.announce_timestamps,
|
|
now=now,
|
|
)
|
|
|
|
def on_telephone_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle lxst.telephony announces (synchronous Reticulum callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
identity_hash = announced_identity.hash.hex()
|
|
if self.is_destination_blocked(identity_hash, context=ctx):
|
|
print(f"Dropping telephone announce from blocked source: {identity_hash}")
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.drop_path(destination_hash)
|
|
return
|
|
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
|
|
# log received announce
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [lxst.telephony]",
|
|
)
|
|
|
|
# track announce timestamp
|
|
self._note_announce_timestamp()
|
|
|
|
# upsert announce to database
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
|
|
# find announce from database
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
|
|
# send database announce to all websocket clients
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_lxmf_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle lxmf.delivery announces (synchronous Reticulum callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
|
|
# check if announced identity or its hash is missing
|
|
if not announced_identity or not announced_identity.hash:
|
|
print(
|
|
f"Dropping announce with missing identity or hash: {RNS.prettyhexrep(destination_hash)}",
|
|
)
|
|
return
|
|
|
|
# check if source is blocked - drop announce and path if blocked
|
|
identity_hash = announced_identity.hash.hex()
|
|
if self.is_destination_blocked(identity_hash, context=ctx):
|
|
print(f"Dropping announce from blocked source: {identity_hash}")
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.drop_path(destination_hash)
|
|
return
|
|
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
|
|
# log received announce
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [lxmf.delivery]",
|
|
)
|
|
|
|
# track announce timestamp
|
|
self._note_announce_timestamp()
|
|
|
|
# upsert announce to database
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
|
|
# find announce from database
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
|
|
# send database announce to all websocket clients
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# resend all failed messages that were intended for this destination
|
|
if ctx.config.auto_resend_failed_messages_when_announce_received.get():
|
|
try:
|
|
path_ready = RNS.Transport.has_path(destination_hash)
|
|
except Exception:
|
|
path_ready = False
|
|
if path_ready:
|
|
AsyncUtils.run_async(
|
|
self.resend_failed_messages_for_destination(
|
|
destination_hash.hex(),
|
|
context=ctx,
|
|
),
|
|
)
|
|
|
|
def on_lxmf_propagation_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle lxmf.propagation announces (synchronous Reticulum callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
|
|
# log received announce
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [lxmf.propagation]",
|
|
)
|
|
|
|
# track announce timestamp
|
|
self._note_announce_timestamp()
|
|
|
|
# upsert announce to database
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
|
|
# find announce from database
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
|
|
# send database announce to all websocket clients
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# resends all messages that previously failed to send to the provided destination hash
|
|
async def resend_failed_messages_for_destination(
|
|
self,
|
|
destination_hash: str,
|
|
context=None,
|
|
):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
|
|
identity_key = ""
|
|
try:
|
|
if ctx.identity is not None and getattr(ctx.identity, "hash", None):
|
|
identity_key = ctx.identity.hash.hex()
|
|
except Exception:
|
|
identity_key = destination_hash
|
|
|
|
lock = self._auto_resend_coordinator.lock_for(identity_key, destination_hash)
|
|
async with lock:
|
|
failed_messages = ctx.database.messages.get_failed_messages_for_destination(
|
|
destination_hash,
|
|
)
|
|
now = time.time()
|
|
|
|
for failed_message in failed_messages:
|
|
try:
|
|
message_hash = failed_message.get("hash")
|
|
if not message_hash:
|
|
continue
|
|
|
|
if should_skip_for_budget(
|
|
failed_message.get("fields"),
|
|
max_attempts=MAX_AUTO_RESEND_ATTEMPTS,
|
|
):
|
|
continue
|
|
|
|
if ctx.database.messages.has_recent_outbound_with_content(
|
|
destination_hash,
|
|
failed_message.get("content"),
|
|
within_seconds=RECENT_SAME_CONTENT_SECONDS,
|
|
now=now,
|
|
):
|
|
continue
|
|
|
|
fields = parse_fields_dict(failed_message.get("fields"))
|
|
allow_attachments = ctx.config.allow_auto_resending_failed_messages_with_attachments.get()
|
|
if not allow_attachments and fields_have_attachments(fields):
|
|
print(
|
|
"Not resending failed message with attachments, as setting is disabled",
|
|
)
|
|
continue
|
|
|
|
claimed = (
|
|
ctx.database.messages.try_claim_failed_message_for_auto_resend(
|
|
message_hash,
|
|
cooldown_until=cooldown_until(
|
|
now,
|
|
seconds=AUTO_RESEND_COOLDOWN_SECONDS,
|
|
),
|
|
now=now,
|
|
)
|
|
)
|
|
if not claimed:
|
|
continue
|
|
|
|
# parse image field
|
|
image_field = None
|
|
if "image" in fields and isinstance(fields.get("image"), dict):
|
|
image_field = LxmfImageField(
|
|
fields["image"]["image_type"],
|
|
base64.b64decode(fields["image"]["image_bytes"]),
|
|
)
|
|
|
|
# parse audio field
|
|
audio_field = None
|
|
if "audio" in fields and isinstance(fields.get("audio"), dict):
|
|
audio_field = LxmfAudioField(
|
|
fields["audio"]["audio_mode"],
|
|
base64.b64decode(fields["audio"]["audio_bytes"]),
|
|
)
|
|
|
|
# parse file attachments field
|
|
file_attachments_field = None
|
|
if "file_attachments" in fields and isinstance(
|
|
fields.get("file_attachments"),
|
|
list,
|
|
):
|
|
file_attachments = [
|
|
LxmfFileAttachment(
|
|
file_attachment["file_name"],
|
|
base64.b64decode(file_attachment["file_bytes"]),
|
|
)
|
|
for file_attachment in fields["file_attachments"]
|
|
if isinstance(file_attachment, dict)
|
|
]
|
|
file_attachments_field = LxmfFileAttachmentsField(
|
|
file_attachments,
|
|
)
|
|
|
|
attempt = next_attempt_count(failed_message.get("fields"))
|
|
ctx.database.messages.set_message_fields_json(
|
|
message_hash,
|
|
fields_with_auto_resend_count(
|
|
failed_message.get("fields"),
|
|
attempt,
|
|
),
|
|
)
|
|
|
|
# send new message with failed message content
|
|
new_message = await self.send_message(
|
|
failed_message["destination_hash"],
|
|
failed_message["content"],
|
|
image_field=image_field,
|
|
audio_field=audio_field,
|
|
file_attachments_field=file_attachments_field,
|
|
context=ctx,
|
|
)
|
|
|
|
# Only drop the old failed row after a replacement was queued.
|
|
if (
|
|
new_message is None
|
|
or getattr(new_message, "hash", None) is None
|
|
):
|
|
continue
|
|
|
|
new_hash = new_message.hash.hex()
|
|
ctx.database.messages.set_auto_resend_count_on_message(
|
|
new_hash,
|
|
attempt,
|
|
)
|
|
|
|
ctx.database.messages.delete_lxmf_message_by_hash(message_hash)
|
|
|
|
# tell all websocket clients that old failed message was deleted so it can remove from ui
|
|
await self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "lxmf_message_deleted",
|
|
"hash": message_hash,
|
|
},
|
|
),
|
|
)
|
|
|
|
except Exception as e:
|
|
print("Error resending failed message: " + str(e))
|
|
|
|
def on_rrc_hub_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle Relay Chat rrc.hub announces for hub discovery."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
if ctx.config and not ctx.config.rrc_enabled.get():
|
|
return
|
|
|
|
identity_hash = announced_identity.hash.hex()
|
|
if self.is_destination_blocked(identity_hash, context=ctx):
|
|
print(f"Dropping rrc.hub announce from blocked source: {identity_hash}")
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.drop_path(destination_hash)
|
|
return
|
|
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [rrc.hub]",
|
|
)
|
|
|
|
self._note_announce_timestamp()
|
|
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def on_nomadnet_node_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle nomadnetwork.node announces (synchronous Reticulum callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
|
|
# check if source is blocked - drop announce and path if blocked
|
|
identity_hash = announced_identity.hash.hex()
|
|
if self.is_destination_blocked(identity_hash, context=ctx):
|
|
print(f"Dropping announce from blocked source: {identity_hash}")
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.drop_path(destination_hash)
|
|
return
|
|
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
|
|
# log received announce
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [nomadnetwork.node]",
|
|
)
|
|
|
|
# track announce timestamp
|
|
self._note_announce_timestamp()
|
|
|
|
# upsert announce to database
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
|
|
# find announce from database
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
|
|
# send database announce to all websocket clients
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
self.queue_crawler_task(
|
|
destination_hash.hex(),
|
|
self.config.nomad_default_page_path.get() or "/page/index.mu",
|
|
)
|
|
|
|
def on_map_data_announce_received(
|
|
self,
|
|
aspect,
|
|
destination_hash,
|
|
announced_identity,
|
|
app_data,
|
|
announce_packet_hash,
|
|
context=None,
|
|
):
|
|
"""Handle map-data-v1 announces (synchronous Reticulum callback)."""
|
|
ctx = context or self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
if not announced_identity or not announced_identity.hash:
|
|
return
|
|
identity_hash = announced_identity.hash.hex()
|
|
if self.is_destination_blocked(identity_hash, context=ctx):
|
|
if hasattr(self, "reticulum") and self.reticulum:
|
|
self.reticulum.drop_path(destination_hash)
|
|
return
|
|
if not ctx.announce_manager.is_storing_announce_for_aspect(aspect):
|
|
return
|
|
print(
|
|
"Received an announce from "
|
|
+ RNS.prettyhexrep(destination_hash)
|
|
+ " for [map-data-v1]",
|
|
)
|
|
self._note_announce_timestamp()
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
announced_identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
announce_packet_hash,
|
|
)
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if not announce:
|
|
return
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
def _try_serve_local_page_node(
|
|
self,
|
|
destination_hash,
|
|
page_path,
|
|
request_data=None,
|
|
link_id=None,
|
|
remote_identity=None,
|
|
):
|
|
"""Serve a page from disk when the hash matches a local page node.
|
|
|
|
Returns the page content string, or None.
|
|
"""
|
|
from meshchatx.src.backend.page_node import _safe_mesh_file_basename
|
|
|
|
for node in self.page_node_manager.nodes.values():
|
|
if not node.running or not node.destination:
|
|
continue
|
|
if node.destination.hash == destination_hash:
|
|
page_name = page_path.lstrip("/")
|
|
page_name = page_name.removeprefix("page/")
|
|
try:
|
|
page_name = _safe_mesh_file_basename(page_name)
|
|
except ValueError:
|
|
return None
|
|
raw = node.serve_page_content(
|
|
page_name,
|
|
data=request_data,
|
|
link_id=link_id,
|
|
remote_identity=remote_identity,
|
|
)
|
|
if raw is None:
|
|
return None
|
|
return raw.decode("utf-8", errors="replace")
|
|
return None
|
|
|
|
def _try_serve_local_page_node_file(self, destination_hash, file_path):
|
|
"""Serve a file from disk when the hash matches a local page node.
|
|
|
|
Returns (file_name, file_bytes), or None.
|
|
"""
|
|
from meshchatx.src.backend.page_node import _safe_mesh_file_basename
|
|
|
|
for node in self.page_node_manager.nodes.values():
|
|
if not node.running or not node.destination:
|
|
continue
|
|
if node.destination.hash == destination_hash:
|
|
file_name = file_path.lstrip("/")
|
|
file_name = file_name.removeprefix("file/")
|
|
try:
|
|
file_name = _safe_mesh_file_basename(file_name)
|
|
except ValueError:
|
|
return None
|
|
return node.read_hosted_file(file_name)
|
|
return None
|
|
|
|
def _register_local_page_node_announce(self, node):
|
|
"""Insert a synthetic announce for a local page node into the database.
|
|
|
|
Ensures the node appears in the NomadNet announce list without RNS loopback.
|
|
"""
|
|
ctx = self.current_context
|
|
if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
|
|
return
|
|
if not node.destination or not node.identity:
|
|
return
|
|
destination_hash = node.destination.hash
|
|
aspect = "nomadnetwork.node"
|
|
app_data = node.name.encode("utf-8")
|
|
ctx.announce_manager.upsert_announce(
|
|
self.reticulum,
|
|
node.identity,
|
|
destination_hash,
|
|
aspect,
|
|
app_data,
|
|
None,
|
|
force_store=True,
|
|
)
|
|
announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
|
|
if announce:
|
|
AsyncUtils.run_async(
|
|
self.websocket_broadcast(
|
|
json.dumps(
|
|
{
|
|
"type": "announce",
|
|
"announce": self.convert_db_announce_to_dict(announce),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
|
|
# queues a crawler task for the provided destination and path
|
|
def queue_crawler_task(self, destination_hash: str, page_path: str, context=None):
|
|
ctx = context or self.current_context
|
|
if not ctx:
|
|
return
|
|
ctx.database.misc.upsert_crawl_task(destination_hash, page_path)
|
|
|
|
# gets the custom display name a user has set for the provided destination hash
|
|
def get_custom_destination_display_name(self, destination_hash: str):
|
|
db_destination_display_name = self.database.announces.get_custom_display_name(
|
|
destination_hash,
|
|
)
|
|
if db_destination_display_name is not None:
|
|
return db_destination_display_name
|
|
|
|
return None
|
|
|
|
# get name to show for an lxmf conversation (from most recent announce app data)
|
|
def get_lxmf_conversation_name(
|
|
self,
|
|
destination_hash,
|
|
default_name: str | None = "Anonymous Peer",
|
|
):
|
|
# Optimized to fetch only the needed announce
|
|
lxmf_announce = self.database.announces.get_announce_by_hash(destination_hash)
|
|
|
|
# if app data is available in database, it should be base64 encoded text that was announced
|
|
# we will return the parsed lxmf display name as the conversation name
|
|
if lxmf_announce is not None and lxmf_announce["app_data"] is not None:
|
|
return parse_lxmf_display_name(
|
|
app_data_base64=lxmf_announce["app_data"],
|
|
)
|
|
|
|
# announce did not have app data, so provide a fallback name
|
|
return default_name
|
|
|
|
# reads the lxmf display name from the provided base64 app data
|
|
|
|
# returns true if the conversation has messages newer than the last read at timestamp
|
|
def is_lxmf_conversation_unread(self, destination_hash):
|
|
return self.database.messages.is_conversation_unread(destination_hash)
|
|
|
|
# returns number of messages that failed to send in a conversation
|
|
def lxmf_conversation_failed_messages_count(self, destination_hash: str):
|
|
return self.database.messages.get_failed_messages_count(destination_hash)
|
|
|
|
# find an interface by name
|
|
@staticmethod
|
|
def find_interface_by_name(name: str):
|
|
for interface in RNS.Transport.interfaces:
|
|
interface_name = str(interface)
|
|
if name == interface_name:
|
|
return interface
|
|
|
|
return None
|
|
|
|
|
|
def _maybe_run_embedded_module():
|
|
"""Re-enter a bundled Python module from a frozen MeshChatX executable.
|
|
|
|
Desktop builds set sys.executable to MeshChatX itself, so
|
|
python -m … cannot be used to start tools like rnsh. Callers pass
|
|
--meshchatx-run-module <dotted.name> followed by that module's argv.
|
|
"""
|
|
marker = "--meshchatx-run-module"
|
|
if len(sys.argv) < 3 or sys.argv[1] != marker:
|
|
return False
|
|
module_name = sys.argv[2]
|
|
if not module_name or module_name.startswith("-"):
|
|
print(
|
|
f"error: {marker} requires a module name",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(2)
|
|
sys.argv = [sys.argv[0], *sys.argv[3:]]
|
|
runpy.run_module(module_name, run_name="__main__", alter_sys=True)
|
|
return True
|
|
|
|
|
|
# class to manage config stored in database
|
|
def main():
|
|
if _maybe_run_embedded_module():
|
|
return
|
|
|
|
# Windows: mirror Linux Landlock by supervising the real process in an
|
|
# AppContainer when requested. Electron already enters via the launcher
|
|
# module. Skip when already inside the container or when this process is
|
|
# the launcher supervisor (MESHCHAT_APPCONTAINER_LAUNCHER=1).
|
|
# One-shot CLI diagnostics and backup tools run in-process so CI and
|
|
# operators are not blocked on CreateProcess into an AppContainer.
|
|
_oneshot_cli_flags = (
|
|
"--self-check",
|
|
"--reset-password",
|
|
"--backup-db",
|
|
"--restore-db",
|
|
"--restore-from-snapshot",
|
|
"--list-backups",
|
|
"--export-backup",
|
|
"--help",
|
|
"-h",
|
|
"--version",
|
|
)
|
|
if (
|
|
sys.platform == "win32"
|
|
and appcontainer_requested()
|
|
and not is_appcontainer_child()
|
|
and os.environ.get("MESHCHAT_APPCONTAINER_LAUNCHER", "").strip()
|
|
not in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
"on",
|
|
)
|
|
and not any(flag in sys.argv for flag in _oneshot_cli_flags)
|
|
):
|
|
from meshchatx.src.backend.appcontainer_launcher import run_launcher
|
|
|
|
os.environ["MESHCHAT_APPCONTAINER_LAUNCHER"] = "1"
|
|
raise SystemExit(run_launcher(sys.argv[1:]))
|
|
|
|
# Initialize crash recovery system early to catch startup errors
|
|
recovery = CrashRecovery()
|
|
recovery.install()
|
|
install_rns_panic_containment()
|
|
|
|
parser = argparse.ArgumentParser(description="ReticulumMeshChat")
|
|
parser.add_argument(
|
|
"--host",
|
|
nargs="?",
|
|
default=os.environ.get("MESHCHAT_HOST", "127.0.0.1"),
|
|
type=str,
|
|
help="The address the web server should listen on. Can also be set via MESHCHAT_HOST environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
nargs="?",
|
|
default=int(os.environ.get("MESHCHAT_PORT", "8000")),
|
|
type=int,
|
|
help="The port the web server should listen on. Can also be set via MESHCHAT_PORT environment variable.",
|
|
)
|
|
# If we are running from a frozen application (AppImage, EXE, etc),
|
|
# we should default to headless mode unless explicitly requested.
|
|
is_frozen = getattr(sys, "frozen", False)
|
|
default_headless = env_bool("MESHCHAT_HEADLESS", is_frozen)
|
|
|
|
parser.add_argument(
|
|
"--headless",
|
|
action="store_true",
|
|
default=default_headless,
|
|
help="Web browser will not automatically launch when this flag is passed. Can also be set via MESHCHAT_HEADLESS environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--identity-file",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_IDENTITY_FILE"),
|
|
help="Path to a Reticulum Identity file to use as your LXMF address. Can also be set via MESHCHAT_IDENTITY_FILE environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--identity-base64",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_IDENTITY_BASE64"),
|
|
help="A base64 encoded Reticulum Identity to use as your LXMF address. Can also be set via MESHCHAT_IDENTITY_BASE64 environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--identity-base32",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_IDENTITY_BASE32"),
|
|
help="A base32 encoded Reticulum Identity to use as your LXMF address. Can also be set via MESHCHAT_IDENTITY_BASE32 environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--generate-identity-file",
|
|
type=str,
|
|
help="Generates and saves a new Reticulum Identity to the provided file path and then exits.",
|
|
)
|
|
parser.add_argument(
|
|
"--generate-identity-base64",
|
|
action="store_true",
|
|
help="Outputs a randomly generated Reticulum Identity as base64 and then exits.",
|
|
)
|
|
parser.add_argument(
|
|
"--auto-recover",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_AUTO_RECOVER", False),
|
|
help="Attempt to automatically recover the SQLite database on startup before serving the app. Can also be set via MESHCHAT_AUTO_RECOVER environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--auth",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_AUTH", False),
|
|
help="Enable basic authentication for the web interface. Can also be set via MESHCHAT_AUTH environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--demo",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_DEMO_MODE", False),
|
|
help="Public demo mode: read-only mesh and blocked API mutations. Can also be set via MESHCHAT_DEMO_MODE environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-https",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_NO_HTTPS", False),
|
|
help="Disable HTTPS and use HTTP instead. Can also be set via MESHCHAT_NO_HTTPS environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--ssl-cert",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_SSL_CERT"),
|
|
metavar="PATH",
|
|
help="Path to PEM TLS certificate. Use with --ssl-key (or MESHCHAT_SSL_KEY). Overrides the default identity storage ssl/cert.pem.",
|
|
)
|
|
parser.add_argument(
|
|
"--ssl-key",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_SSL_KEY"),
|
|
metavar="PATH",
|
|
help="Path to PEM TLS private key. Use with --ssl-cert (or MESHCHAT_SSL_CERT). Overrides the default identity storage ssl/key.pem.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-crash-recovery",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_NO_CRASH_RECOVERY", False),
|
|
help="Disable the crash recovery and diagnostic system. Can also be set via MESHCHAT_NO_CRASH_RECOVERY environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--backup-db",
|
|
type=str,
|
|
help="Create a database backup zip at the given path and exit.",
|
|
)
|
|
parser.add_argument(
|
|
"--list-backups",
|
|
action="store_true",
|
|
help="List automatic database backups in storage (JSON) and exit.",
|
|
)
|
|
parser.add_argument(
|
|
"--export-backup",
|
|
nargs="*",
|
|
default=None,
|
|
metavar="ARG",
|
|
help=(
|
|
"Export a backup and exit. One argument: write a new zip to PATH. "
|
|
"Two arguments: copy backup NAME from storage to DEST."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--restore-db",
|
|
type=str,
|
|
help="Restore the database from the given path (zip or db file) and exit.",
|
|
)
|
|
parser.add_argument(
|
|
"--data-dir",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_DATA_DIR"),
|
|
help=(
|
|
"Portable data root: uses <dir>/storage and <dir>/.reticulum when "
|
|
"--storage-dir and --reticulum-config-dir are not set. "
|
|
"Can also be set via MESHCHAT_DATA_DIR."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--reticulum-config-dir",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_RETICULUM_CONFIG_DIR"),
|
|
help="Path to a Reticulum config directory for the RNS stack to use (e.g: ~/.reticulum). Can also be set via MESHCHAT_RETICULUM_CONFIG_DIR environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--storage-dir",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_STORAGE_DIR"),
|
|
help="Path to a directory for storing databases and config files (default: ./storage). Can also be set via MESHCHAT_STORAGE_DIR environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--public-dir",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_PUBLIC_DIR"),
|
|
help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--gitea-base-url",
|
|
type=str,
|
|
default=os.environ.get("MESHCHAT_GITEA_BASE_URL"),
|
|
help="Base URL for Gitea instance. Can also be set via MESHCHAT_GITEA_BASE_URL environment variable.",
|
|
)
|
|
parser.add_argument(
|
|
"--test-exception-message",
|
|
type=str,
|
|
help="Throws an exception. Used for testing the electron error dialog",
|
|
)
|
|
parser.add_argument(
|
|
"args",
|
|
nargs=argparse.REMAINDER,
|
|
) # allow unknown command line args
|
|
parser.add_argument(
|
|
"--emergency",
|
|
action="store_true",
|
|
help="Start in emergency mode (no database, LXMF and peer announces only). Can also be set via MESHCHAT_EMERGENCY environment variable.",
|
|
default=env_bool("MESHCHAT_EMERGENCY", False),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--rns-log-level",
|
|
type=str,
|
|
default=None,
|
|
metavar="LEVEL",
|
|
help=(
|
|
"Reticulum (RNS) stack log level: none, critical, error, warning, notice, "
|
|
"verbose, debug, extreme, or a numeric level. "
|
|
"When set, overrides MESHCHAT_RNS_LOG_LEVEL."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--restore-from-snapshot",
|
|
type=str,
|
|
help="Restore the database from a specific snapshot name or path on startup.",
|
|
default=os.environ.get("MESHCHAT_RESTORE_SNAPSHOT"),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--reset-password",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_RESET_PASSWORD", False),
|
|
help="Clear the stored password hash on startup so a new password can be set via the web UI. Can also be set via MESHCHAT_RESET_PASSWORD environment variable.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--memory-diag",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_MEMORY_DIAG", False),
|
|
help="Enable tracemalloc-based memory diagnostics. Can also be set via MESHCHAT_MEMORY_DIAG environment variable.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--disable-plugins",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_DISABLE_PLUGINS", False),
|
|
help="Disable the plugin system entirely. Can also be set via MESHCHAT_DISABLE_PLUGINS environment variable.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--self-check",
|
|
action="store_true",
|
|
default=env_bool("MESHCHAT_SELF_CHECK", False),
|
|
help="Run system self-check diagnostics on startup and then exit with 0 if all checks pass, or 1 if any fail. Can also be set via MESHCHAT_SELF_CHECK environment variable.",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
ssl_cert = (args.ssl_cert or "").strip() or None
|
|
ssl_key = (args.ssl_key or "").strip() or None
|
|
if bool(ssl_cert) != bool(ssl_key):
|
|
parser.error(
|
|
"Both --ssl-cert and --ssl-key (or MESHCHAT_SSL_CERT and MESHCHAT_SSL_KEY) must be set together.",
|
|
)
|
|
|
|
# Disable crash recovery if requested via flag
|
|
if args.no_crash_recovery:
|
|
recovery.disable()
|
|
|
|
args.storage_dir, args.reticulum_config_dir = resolve_meshchat_data_roots(
|
|
data_dir=args.data_dir,
|
|
storage_dir=args.storage_dir,
|
|
reticulum_config_dir=args.reticulum_config_dir,
|
|
)
|
|
|
|
planned_storage_dir = args.storage_dir
|
|
if not planned_storage_dir:
|
|
# On Android, prefer user-accessible external storage
|
|
android_external = _get_android_external_files_dir()
|
|
if android_external:
|
|
planned_storage_dir = android_external
|
|
else:
|
|
planned_storage_dir = os.path.join("storage")
|
|
effective_storage_dir, migration_context = resolve_startup_storage(
|
|
planned_storage_dir,
|
|
)
|
|
args.storage_dir = effective_storage_dir
|
|
recovery.update_paths(
|
|
storage_dir=effective_storage_dir,
|
|
reticulum_config_dir=args.reticulum_config_dir,
|
|
)
|
|
|
|
# check if we want to test exception messages
|
|
if args.test_exception_message is not None:
|
|
raise Exception(args.test_exception_message)
|
|
|
|
# util to generate reticulum identity and save to file without using rnid
|
|
if args.generate_identity_file is not None:
|
|
# do not overwrite existing files, otherwise user could lose existing keys
|
|
if os.path.exists(args.generate_identity_file):
|
|
print(
|
|
"DANGER: the provided identity file path already exists, not overwriting!",
|
|
)
|
|
return
|
|
|
|
# generate a new identity and save to provided file path
|
|
identity = RNS.Identity(create_keys=True)
|
|
with open(args.generate_identity_file, "wb") as file:
|
|
file.write(identity.get_private_key())
|
|
|
|
print(
|
|
f"A new Reticulum Identity has been saved to: {args.generate_identity_file}",
|
|
)
|
|
return
|
|
|
|
# util to generate reticulum identity as base64 without using rnid
|
|
if args.generate_identity_base64 is True:
|
|
identity = RNS.Identity(create_keys=True)
|
|
print(base64.b64encode(identity.get_private_key()).decode("utf-8"))
|
|
return
|
|
|
|
identity_file_path = None
|
|
|
|
# use provided identity, or fallback to a random one
|
|
if args.identity_file is not None:
|
|
identity = RNS.Identity(create_keys=False)
|
|
identity.load(args.identity_file)
|
|
identity_file_path = args.identity_file
|
|
print(
|
|
f"Reticulum Identity <{identity.hash.hex()}> has been loaded from file {args.identity_file}.",
|
|
)
|
|
elif args.identity_base64 is not None or args.identity_base32 is not None:
|
|
identity = RNS.Identity(create_keys=False)
|
|
if args.identity_base64 is not None:
|
|
identity.load_private_key(base64.b64decode(args.identity_base64))
|
|
else:
|
|
try:
|
|
identity.load_private_key(
|
|
base64.b32decode(args.identity_base32, casefold=True),
|
|
)
|
|
except Exception as exc:
|
|
msg = f"Invalid base32 identity: {exc}"
|
|
raise ValueError(msg) from exc
|
|
base_storage_dir = args.storage_dir or os.path.join("storage")
|
|
os.makedirs(base_storage_dir, exist_ok=True)
|
|
default_identity_file = os.path.join(base_storage_dir, "identity")
|
|
if not os.path.exists(default_identity_file):
|
|
with open(default_identity_file, "wb") as file:
|
|
file.write(identity.get_private_key())
|
|
identity_file_path = default_identity_file
|
|
print(
|
|
f"Reticulum Identity <{identity.hash.hex()}> has been loaded from provided key.",
|
|
)
|
|
else:
|
|
# ensure provided storage dir exists, or the default storage dir exists
|
|
base_storage_dir = args.storage_dir or os.path.join("storage")
|
|
os.makedirs(base_storage_dir, exist_ok=True)
|
|
|
|
# configure path to default identity file
|
|
default_identity_file = os.path.join(base_storage_dir, "identity")
|
|
|
|
# if default identity file does not exist, generate a new identity and save it
|
|
if not os.path.exists(default_identity_file):
|
|
identity = RNS.Identity(create_keys=True)
|
|
with open(default_identity_file, "wb") as file:
|
|
file.write(identity.get_private_key())
|
|
print(
|
|
f"Reticulum Identity <{identity.hash.hex()}> has been randomly generated and saved to {default_identity_file}.",
|
|
)
|
|
|
|
# default identity file exists, load it
|
|
identity = RNS.Identity(create_keys=False)
|
|
identity.load(default_identity_file)
|
|
identity_file_path = default_identity_file
|
|
print(
|
|
f"Reticulum Identity <{identity.hash.hex()}> has been loaded from file {default_identity_file}.",
|
|
)
|
|
|
|
# init app (allow optional one-shot backup/restore before running)
|
|
rns_log_cli = (args.rns_log_level or "").strip() or None
|
|
|
|
mem_check = evaluate_startup_memory(args.emergency)
|
|
print(format_memory_log_line(mem_check), flush=True)
|
|
if mem_check.get("message"):
|
|
print(mem_check["message"], flush=True)
|
|
if mem_check["action"] == "abort":
|
|
print(
|
|
"Startup aborted due to critically low memory. "
|
|
"Free RAM or relaunch with --emergency.",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
sys.exit(1)
|
|
|
|
needs_immediate_network = bool(
|
|
args.self_check
|
|
or args.reset_password
|
|
or args.backup_db
|
|
or args.list_backups
|
|
or args.export_backup is not None
|
|
or args.restore_db
|
|
or args.restore_from_snapshot,
|
|
)
|
|
if auth_bypass_from_env():
|
|
print(
|
|
"WARNING: MESHCHAT_AUTH_BYPASS=1 disables web UI authentication",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
demo_mode = bool(args.demo)
|
|
altcha_on = altcha_enabled_from_env()
|
|
|
|
reticulum_meshchat = ReticulumMeshChat(
|
|
identity,
|
|
args.storage_dir,
|
|
args.reticulum_config_dir,
|
|
auto_recover=args.auto_recover,
|
|
identity_file_path=identity_file_path,
|
|
auth_enabled=args.auth,
|
|
public_dir=args.public_dir,
|
|
emergency=args.emergency,
|
|
gitea_base_url=args.gitea_base_url,
|
|
ssl_cert_path=ssl_cert,
|
|
ssl_key_path=ssl_key,
|
|
rns_loglevel=rns_log_cli,
|
|
migration_context=migration_context,
|
|
memory_diag_enabled=args.memory_diag,
|
|
plugins_enabled=(not args.disable_plugins) and not demo_mode,
|
|
defer_network_setup=not needs_immediate_network,
|
|
headless=bool(args.headless),
|
|
demo_mode=demo_mode,
|
|
altcha_enabled=altcha_on,
|
|
)
|
|
|
|
# store recovery on app for wiring with identity context
|
|
reticulum_meshchat._crash_recovery = recovery
|
|
|
|
# update recovery with known paths (database_path may be unset until identity setup)
|
|
recovery.update_paths(
|
|
storage_dir=reticulum_meshchat.storage_dir,
|
|
database_path=reticulum_meshchat.database_path,
|
|
public_dir=reticulum_meshchat.public_dir_override or get_file_path("public"),
|
|
reticulum_config_dir=reticulum_meshchat.reticulum_config_dir,
|
|
)
|
|
|
|
if args.self_check:
|
|
results = reticulum_meshchat.run_self_test()
|
|
print("\n================================", flush=True)
|
|
print(" System Self-Check Results", flush=True)
|
|
print("================================", flush=True)
|
|
|
|
all_passed = True
|
|
from meshchatx.src.backend.self_check import SELF_CHECK_LABELS
|
|
|
|
for key, name in SELF_CHECK_LABELS.items():
|
|
check = results.get(key, {"status": "failed", "reason": "No result"})
|
|
if check["status"] == "ok":
|
|
print(f"[OK] {name}", flush=True)
|
|
else:
|
|
all_passed = False
|
|
reason = check.get("reason") or "Unknown error"
|
|
print(f"[FAILED] {name} - Reason: {reason}", flush=True)
|
|
|
|
print("================================", flush=True)
|
|
if all_passed:
|
|
print("Status: SUCCESS (All checks passed)", flush=True)
|
|
print("================================\n", flush=True)
|
|
sys.exit(0)
|
|
else:
|
|
print("Status: FAILED", flush=True)
|
|
print("================================\n", flush=True)
|
|
sys.exit(1)
|
|
|
|
if args.reset_password:
|
|
if reticulum_meshchat.reset_password():
|
|
print("Password has been reset. Set a new password via the web UI.")
|
|
else:
|
|
print("No password was set; nothing to reset.")
|
|
|
|
if args.backup_db:
|
|
result = reticulum_meshchat.backup_database(args.backup_db)
|
|
print(f"Backup written to {result['path']} ({result['size']} bytes)")
|
|
return
|
|
|
|
if args.list_backups:
|
|
backups = reticulum_meshchat.list_database_backups()
|
|
print(json.dumps({"backups": backups, "total": len(backups)}, indent=2))
|
|
return
|
|
|
|
if args.export_backup is not None:
|
|
parts = args.export_backup
|
|
if len(parts) == 1:
|
|
result = reticulum_meshchat.backup_database(parts[0])
|
|
print(f"Backup written to {result['path']} ({result['size']} bytes)")
|
|
elif len(parts) == 2:
|
|
result = reticulum_meshchat.export_database_backup(parts[0], parts[1])
|
|
print(
|
|
f"Exported {result['name']} to {result['path']} ({result['size']} bytes)",
|
|
)
|
|
else:
|
|
print(
|
|
"Usage: --export-backup PATH | --export-backup NAME DEST",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
return
|
|
|
|
if args.restore_db:
|
|
result = reticulum_meshchat.restore_database(args.restore_db)
|
|
print(f"Restored database from {args.restore_db}")
|
|
print(f"Integrity check: {result['integrity_check']}")
|
|
return
|
|
|
|
if args.restore_from_snapshot:
|
|
snapshot_path = args.restore_from_snapshot
|
|
if not os.path.exists(snapshot_path):
|
|
# Try in identity storage snapshots
|
|
potential_path = os.path.join(
|
|
reticulum_meshchat.storage_path,
|
|
"snapshots",
|
|
snapshot_path,
|
|
)
|
|
if os.path.exists(potential_path):
|
|
snapshot_path = potential_path
|
|
elif os.path.exists(potential_path + ".zip"):
|
|
snapshot_path = potential_path + ".zip"
|
|
|
|
if os.path.exists(snapshot_path):
|
|
print(f"Restoring database from snapshot: {snapshot_path}")
|
|
result = reticulum_meshchat.restore_database(snapshot_path)
|
|
print(
|
|
f"Snapshot restoration complete. Integrity check: {result['integrity_check']}",
|
|
)
|
|
reticulum_meshchat.setup_identity(identity)
|
|
reticulum_meshchat._mark_network_ready()
|
|
reticulum_meshchat._finish_deferred_startup_services()
|
|
else:
|
|
print(f"Error: Snapshot not found at {snapshot_path}")
|
|
|
|
enable_https = not args.no_https
|
|
reticulum_meshchat.appcontainer_active = is_appcontainer_child()
|
|
if reticulum_meshchat.appcontainer_active:
|
|
apply_windows_process_mitigations()
|
|
reticulum_meshchat.landlock_active = apply_landlock_sandbox(
|
|
storage_dir=reticulum_meshchat.storage_dir,
|
|
reticulum_config_dir=reticulum_meshchat.reticulum_config_dir,
|
|
public_dir=reticulum_meshchat.public_dir_override or get_file_path("public"),
|
|
log_dir=resolve_log_dir(),
|
|
extra_read_roots=extra_read_roots_from_app(reticulum_meshchat),
|
|
)
|
|
# Apply after Landlock so landlock_* syscalls are not blocked by the filter.
|
|
reticulum_meshchat.seccomp_active = apply_seccomp_sandbox()
|
|
reticulum_meshchat.run(
|
|
args.host,
|
|
args.port,
|
|
launch_browser=args.headless is False,
|
|
enable_https=enable_https,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|