diff --git a/libervia/backend/bridge/bridge_constructor/bridge_template.ini b/libervia/backend/bridge/bridge_constructor/bridge_template.ini index 67c21451..4e76f4c3 100644 --- a/libervia/backend/bridge/bridge_constructor/bridge_template.ini +++ b/libervia/backend/bridge/bridge_constructor/bridge_template.ini @@ -1113,3 +1113,24 @@ sig_out= doc=Cleans expired notifications and older profile-specific notifications doc_param_0=limit_timestamp: Timestamp limit for older notifications. If -1.0, only truly expired notifications are removed. doc_param_1=profile_key: Profile key (use "@NONE@" to indicate no specific profile, otherwise only notification for given profile will be expired, in addition to truly expired notifications). + +[tor_infos_get] +async= +type=method +category=core +sig_in=ss +sig_out=s +doc=Get informations on running Tor daemon and its state with current profile +doc_param_0=extra: extra data +doc_param_1=%(doc_profile_key)s +doc_return=serialised ``libervia.backend.models.tor.TorData``. + +[tor_mode_get] +async= +type=method +category=core +sig_in=s +sig_out=s +doc=Tell is Tor is used for this profile. +doc_param_0=%(doc_profile_key)s +doc_return=State of the use of Tor for this profile. diff --git a/libervia/backend/bridge/dbus_bridge.py b/libervia/backend/bridge/dbus_bridge.py index c2ad22a5..e3d057a0 100644 --- a/libervia/backend/bridge/dbus_bridge.py +++ b/libervia/backend/bridge/dbus_bridge.py @@ -162,6 +162,7 @@ class DBusObject(objects.DBusObject): Method("session_infos_get", arguments="s", returns="a{ss}"), Method("sub_waiting_get", arguments="s", returns="a{ss}"), Method("subscription", arguments="sss", returns=""), + Method("tor_infos_get", arguments="ss", returns="s"), Method("version_get", arguments="", returns="s"), Signal("_debug", "sa{ss}s"), Signal("action_new", "ssis"), @@ -530,6 +531,9 @@ class DBusObject(objects.DBusObject): def dbus_subscription(self, sub_type, entity, profile_key="@DEFAULT@"): return self._callback("subscription", sub_type, entity, profile_key) + def dbus_tor_infos_get(self, extra, profile_key): + return self._callback("tor_infos_get", extra, profile_key) + def dbus_version_get( self, ): diff --git a/libervia/backend/core/core_types.py b/libervia/backend/core/core_types.py index a2cd660e..7829a175 100644 --- a/libervia/backend/core/core_types.py +++ b/libervia/backend/core/core_types.py @@ -33,6 +33,9 @@ from libervia.backend.models.core import MessageData if TYPE_CHECKING: from libervia.backend.core.xmpp import LiberviaRosterProtocol + from libervia.backend.models.tor import TorMode + from treq.client import HTTPClient + from txtorcon import Tor EncryptionPlugin = namedtuple( @@ -79,8 +82,33 @@ class SatXMPPEntity(ABC): jid: t_jid.JID is_component: bool server_jid: t_jid.JID + port: int identities: list[disco.DiscoIdentity] xmlstream: xmlstream.XmlStream + tor: "Tor | None" = None + + @property + def tor_mode(self) -> "TorMode": + """Return Tor mode for this profile.""" + raise NotImplementedError + + @property + @abstractmethod + def treq(self) -> "HTTPClient": + """Get the HTTP client. + + @return (HTTPClient): the HTTP client + """ + raise NotImplementedError + + @property + @abstractmethod + def treq_ignore_tls(self) -> "HTTPClient": + """Get the HTTP client ignoring TLS errors. + + @return (HTTPClient): the HTTP client ignoring TLS errors + """ + raise NotImplementedError @classmethod @abstractmethod diff --git a/libervia/backend/core/exceptions.py b/libervia/backend/core/exceptions.py index c3586bbf..546e3ab0 100644 --- a/libervia/backend/core/exceptions.py +++ b/libervia/backend/core/exceptions.py @@ -98,11 +98,17 @@ class InternalError(Exception): pass +class InitError(Exception): + """Something went wrong during initialisation.""" + + class FeatureNotFound( Exception ): # a disco feature/identity which is needed is not present pass +class FeatureBlocked(Exception): + """Feature is implemented but blocked.""" class BridgeInitError(Exception): pass diff --git a/libervia/backend/core/main.py b/libervia/backend/core/main.py index bdae5e0b..1bb07f96 100644 --- a/libervia/backend/core/main.py +++ b/libervia/backend/core/main.py @@ -23,14 +23,12 @@ import hashlib import copy from pathlib import Path import shutil -from typing import Optional, List, Tuple, Dict - -from pydantic import ValidationError +from typing import Optional, List, Tuple, Dict, cast from libervia import backend from libervia.backend.core.i18n import _, D_, language_switch from libervia.backend.core import patches -from libervia.backend.tools.config import DBusBridgeSettings +from libervia.backend.models.tor import TorData, TorMode patches.apply() from twisted.application import app, service @@ -41,7 +39,7 @@ from wokkel.xmppim import RosterItem from libervia.backend import G from libervia.backend.core import xmpp from libervia.backend.core import exceptions -from libervia.backend.core.core_types import SatXMPPEntity +from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity from libervia.backend.core.log import getLogger from libervia.backend.core.constants import Const as C @@ -271,6 +269,8 @@ class LiberviaBackend(service.Service): self.bridge.register_method( "notifications_expired_clean", self.memory._notifications_expired_clean ) + self.bridge.register_method("tor_infos_get", self._get_tor_infos) + self.bridge.register_method("tor_mode_get", self._tor_mode_get) await self.memory.initialise() self.common_cache = cache.Cache(self, None) @@ -805,6 +805,8 @@ class LiberviaBackend(service.Service): data = {"jid": client.jid.full(), "started": str(int(client.started))} return defer.succeed(data) + # infos + def _get_devices_infos(self, bare_jid, profile_key): client = self.get_client(profile_key) if not bare_jid: @@ -866,6 +868,28 @@ class LiberviaBackend(service.Service): return ret_data + def _get_tor_infos(self, extra_s: str, profile_key: str) -> defer.Deferred[str]: + client = self.get_client(profile_key) + d = defer.ensureDeferred( + self.get_tor_infos(client, data_format.deserialise(extra_s)) + ) + d.addCallback(lambda tor_data: tor_data.model_dump_json()) + return cast(defer.Deferred[str], d) + + async def get_tor_infos(self, client: SatXMPPClient, extra: dict) -> TorData: + """Return various informations on Tor daemon and its state with current profile. + + @param client: Client instance. + @param extra: Extra data (reserver for future use). + @return: Tor data + @raise exceptions.FeatureNotFound: Tor is not available for this profile. + """ + return await TorData.from_client(client) + + def _tor_mode_get(self, profile_key: str) -> defer.Deferred[str]: + client = self.get_client(profile_key) + return defer.succeed(client.tor_mode) + # images def _image_check(self, path): diff --git a/libervia/backend/core/xmpp.py b/libervia/backend/core/xmpp.py index efd5ccf7..f31d04d0 100644 --- a/libervia/backend/core/xmpp.py +++ b/libervia/backend/core/xmpp.py @@ -24,14 +24,18 @@ import mimetypes from pathlib import Path import sys import time -from typing import Any, Callable, Dict, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, cast from urllib.parse import unquote, urlparse import uuid import shortuuid +from treq.api import default_pool +from treq.client import HTTPClient from twisted.internet import defer, error as internet_error, reactor from twisted.internet import ssl +from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint from twisted.python import failure +from twisted.web.client import Agent from twisted.words.protocols.jabber import xmlstream from twisted.words.protocols.jabber import error from twisted.words.protocols.jabber import jid @@ -52,12 +56,26 @@ from libervia.backend.memory import cache from libervia.backend.memory import encryption from libervia.backend.memory import persistent from libervia.backend.models.core import MessageData +from libervia.backend.models.tor import TorData, TorMode from libervia.backend.tools import xml_tools from libervia.backend.tools import utils from libervia.backend.tools.common import data_format log = getLogger(__name__) +if TYPE_CHECKING: + import txtorcon + + from txtorcon import Tor + from txtorcon.util import default_control_port +else: + try: + import txtorcon + from txtorcon import Tor + from txtorcon.util import default_control_port + except ImportError: + txtorcon = None + NS_DISCO_INFO = "http://jabber.org/protocol/disco#info" NS_ROSTER_VER = "urn:xmpp:features:rosterver" @@ -115,6 +133,8 @@ class SatXMPPEntity(core_types.SatXMPPEntity): self._connected_d = None self.profile = profile self.host_app = host_app + self._treq: HTTPClient | None = None + self._treq_ignore_tls: HTTPClient | None = None self.cache = cache.Cache(host_app, profile) self.mess_id2uid = {} # map from message id to uid used in history. # Key: (full_jid, message_id) Value: uid @@ -131,6 +151,44 @@ class SatXMPPEntity(core_types.SatXMPPEntity): def __repr__(self): return f"{super().__repr__()} - profile: {self.profile!r}" + @property + def tor_mode(self) -> TorMode: + """Return Tor mode for this profile.""" + if self.tor is None: + return TorMode.DISABLED + else: + return TorMode.ENABLED_NO_WEBRTC + + @property + def treq(self) -> HTTPClient: + """Get treq instance, using Tor if activated.""" + if self._treq is None: + if self.tor is not None: + treq = HTTPClient(agent=self.tor.web_agent()) # type: ignore + self._treq = treq + else: + pool = default_pool(reactor, None, None) + agent = Agent(reactor, pool=pool) + self._treq = HTTPClient(agent=agent) # type: ignore + return self._treq + + @property + def treq_ignore_tls(self) -> HTTPClient: + """Get treq instance WHICH DOESN'T CHECK TLS. Tor is used if activated. + + This is obviously highly insecure and should not be used without explicit warning. + In the case where Tor is use, it can be legitimate to ignore TLS for .onion + domains. + """ + if self._treq_ignore_tls is None: + if self.tor is not None: + self._treq_ignore_tls = HTTPClient(agent=self.tor.web_agent(tls_context_factory=NoCheckContextFactory())) # type: ignore + else: + pool = default_pool(reactor, None, None) + agent = Agent(reactor, contextFactory=NoCheckContextFactory(), pool=pool) # type: ignore + self._treq_ignore_tls = HTTPClient(agent=agent) # type: ignore + return self._treq_ignore_tls + ## initialisation ## async def _call_connection_triggers(self, connection_timer): @@ -198,6 +256,28 @@ class SatXMPPEntity(core_types.SatXMPPEntity): timer["connected"] = time.time() - connected_start timer["total"] += timer["connected"] + @staticmethod + async def _prepare_tor() -> Tor: + log.debug("We have an .onion domain, we use Tor to connect.") + if txtorcon is None: + raise exceptions.MissingModule( + '"txtorcon" module is not available, we can\'t use Tor. Please ' + "install it and make sur that Tor daemon is available." + ) + try: + config = G.config.tor + if config.control_path is not None: + endpoint = UNIXClientEndpoint(reactor, config.control_path) + else: + endpoint = TCP4ClientEndpoint( + reactor, config.control_address, config.control_port + ) + return await txtorcon.connect(reactor, endpoint) + except Exception as e: + msg = f"Can't connect to Tor: {e}" + log.error(msg) + raise exceptions.InitError(msg) + @classmethod async def start_connection(cls, host, profile, max_retries): """instantiate the entity and start the connection""" @@ -261,6 +341,14 @@ class SatXMPPEntity(core_types.SatXMPPEntity): ) log.debug(f"removing unconnected profile {profile!r}") del host.profiles[profile] + + kwargs = {} + + if not cls.is_component and ( + entity_jid.host.endswith(".onion") or G.config.tor.enabled + ): + kwargs["tor"] = await cls._prepare_tor() + entity = host.profiles[profile] = cls( host, profile, @@ -272,6 +360,7 @@ class SatXMPPEntity(core_types.SatXMPPEntity): or None, port, max_retries, + **kwargs, ) await entity.encryption.load_sessions() @@ -950,6 +1039,7 @@ class SatXMPPClient(SatXMPPEntity, core_types.SatXMPPClient, wokkel_client.XMPPC host=None, port=C.XMPP_C2S_PORT, max_retries=C.XMPP_MAX_RETRIES, + tor: Tor | None = None, ): # XXX: DNS SRV records are checked when the host is not specified. # If no SRV record is found, the host is directly extracted from the JID. @@ -991,7 +1081,7 @@ class SatXMPPClient(SatXMPPEntity, core_types.SatXMPPClient, wokkel_client.XMPPC "check_certificate", "Connection", profile_key=profile ) - if self.check_certificate: + if self.check_certificate and tor is None: tls_required, configurationForTLS = True, None else: tls_required = False @@ -1007,10 +1097,11 @@ class SatXMPPClient(SatXMPPEntity, core_types.SatXMPPClient, wokkel_client.XMPPC configurationForTLS=configurationForTLS, ) SatXMPPEntity.__init__(self, host_app, profile, max_retries) + self.tor = tor if not self.check_certificate: msg = _( - "Certificate validation is deactivated, this is unsecure and " + "Certificate validation is deactivated, this is insecure and " "somebody may be spying on you. If you have no good reason to disable " 'certificate validation, please activate "Check certificate" in your ' 'settings in "Connection" tab.' @@ -1038,6 +1129,19 @@ class SatXMPPClient(SatXMPPEntity, core_types.SatXMPPClient, wokkel_client.XMPPC self.presence = SatPresenceProtocol(self.host_app) self.presence.setHandlerParent(self) + def _getConnection(self): + if self.tor is not None: + assert self.tor is not None + log.info(f"Connecting {self.profile} via Tor.") + try: + endpoint = self.tor.stream_via(self.jid.host, self.port) + return endpoint.connect(self.factory) + except Exception as e: + log.exception("Can't connect to Tor domain.") + raise exceptions.NetworkError(str(e)) + else: + return super()._getConnection() + @classmethod async def start_connection(cls, host, profile, max_retries): try: diff --git a/libervia/backend/models/tor.py b/libervia/backend/models/tor.py new file mode 100644 index 00000000..2f0b776c --- /dev/null +++ b/libervia/backend/models/tor.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +# Libervia Tor models +# Copyright (C) 2009-2025 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from enum import StrEnum, auto +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel, Field + +from libervia.backend.core import exceptions +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +if TYPE_CHECKING: + from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity + + +class TorMode(StrEnum): + #: Tor is completely disabled. + DISABLED = auto() + #: Tor is enable for all connections, but WebRTC is disabled (to avoid leaking IP). + ENABLED_NO_WEBRTC = auto() + + +class Relay(BaseModel): + id: str = Field(description="Hex-encoded router identity fingerprint") + name: str = Field(description="Router nickname or name") + ip: str = Field(description="IP address") + flags: list = Field(description='Router flags (e.g., "Guard", "Fast")') + bandwidth: int | None = Field( + default=None, description="Reported bandwidth in bytes/sec" + ) + country: str = Field(description="Country code (ISO 3166-1 alpha-2)") + details_url: str = Field( + default_factory=lambda data: f"https://metrics.torproject.org/rs.html#details/{data['id']}", + description="URL of metrics website with more details on the relay.", + ) + + +class Circuit(BaseModel): + id: int = Field(description="Unique identifier for the circuit") + state: str = Field( + description="Current state of the circuit (e.g., 'BUILT', 'FAILED')" + ) + purpose: str = Field( + description="Purpose of the circuit (e.g., 'GENERAL', 'HS_CLIENT_INTRO')" + ) + path: list[Relay] = Field(description="List of relays in the circuit path") + + +class Traffic(BaseModel): + read: int = Field(description="Bytes read (downloaded) since Tor started") + written: int = Field(description="Bytes written (uploaded) since Tor started") + + +class TorData(BaseModel): + mode: TorMode = Field(description="In which mode Tor active") + version: str = Field(description="Tor version") + uptime: int = Field(description="Uptime of the Tor daemon in seconds") + traffic: Traffic = Field(description="Global traffic statistics") + circuit: Circuit | None = Field( + default=None, description="Circuit used for XMPP connection" + ) + + @classmethod + async def from_client(cls, client: "SatXMPPClient") -> Self: + """Built an instance from ``client.tor``. + + @return: Instance of TorData + """ + if client.tor is None: + raise exceptions.FeatureNotFound("Tor is not used for this profile.") + host = client.jid.host + + # Initialize with basic version info + infos = { + "mode": client.tor_mode, + "version": client.tor.version, + "uptime": None, + "traffic": None, + "circuit": None, + } + + state = await client.tor.create_state() + + for stream in state.streams.values(): + if ( + stream.target_host == host + and stream.target_port == client.port + and stream.circuit is not None + and stream.state == "SUCCEEDED" + ): + circuit = stream.circuit + # We have the active stream. + router_infos = [] + for router in circuit.path: + try: + location = await router.get_location() + country = location.countrycode + except Exception: + log.exception("Can't get location.") + country = "UNKNOWN" + + router_info = Relay( + id=router.id_hex.lstrip("$"), + name=router.name, + ip=router.ip, + flags=router.flags, + bandwidth=getattr(router, "bandwidth", None), + country=country, + ) + router_infos.append(router_info) + + circuit_info = Circuit( + id=circuit.id, + state=circuit.state, + purpose=circuit.purpose, + path=router_infos, + ) + infos["circuit"] = circuit_info + + try: + infos_dict = await client.tor.protocol.get_info( + "uptime", "traffic/read", "traffic/written" + ) + except Exception: + log.exception("Can't get Tor infos.") + raise exceptions.DataError("Tor infos not available.") + else: + if "uptime" in infos_dict: + infos["uptime"] = infos_dict["uptime"] + if "traffic/read" in infos_dict and "traffic/written" in infos_dict: + infos["traffic"] = Traffic( + read=infos_dict["traffic/read"].strip(), + written=infos_dict["traffic/written"].strip(), + ) + + return cls(**infos) diff --git a/libervia/backend/plugins/plugin_misc_download.py b/libervia/backend/plugins/plugin_misc_download.py index 97930413..f6a0de45 100644 --- a/libervia/backend/plugins/plugin_misc_download.py +++ b/libervia/backend/plugins/plugin_misc_download.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any, Dict, Optional, Union, Tuple, Callable from urllib.parse import unquote, urlparse -import treq +from treq import collect from twisted.internet import defer from twisted.words.protocols.jabber import error as jabber_error @@ -33,7 +33,6 @@ from libervia.backend.core.log import getLogger from libervia.backend.tools import xml_tools from libervia.backend.tools import stream from libervia.backend.tools.common import data_format -from libervia.backend.tools.web import treq_client_no_ssl log = getLogger(__name__) @@ -344,9 +343,9 @@ class DownloadPlugin(object): if options.get("ignore_tls_errors", False): log.warning("TLS certificate check disabled, this is highly insecure") - treq_client = treq_client_no_ssl + treq_client = client.treq_ignore_tls else: - treq_client = treq + treq_client = client.treq head_data = await treq_client.head(url) try: @@ -366,7 +365,7 @@ class DownloadPlugin(object): resp = await treq_client.get(url, unbuffered=True) if resp.code == 200: - d = treq.collect(resp, file_obj.write) + d = collect(resp, file_obj.write) d.addBoth(lambda _: file_obj.close()) else: d = defer.Deferred() diff --git a/libervia/backend/plugins/plugin_sec_aesgcm.py b/libervia/backend/plugins/plugin_sec_aesgcm.py index 60c39fd7..85ddee7b 100644 --- a/libervia/backend/plugins/plugin_sec_aesgcm.py +++ b/libervia/backend/plugins/plugin_sec_aesgcm.py @@ -26,14 +26,13 @@ from cryptography.hazmat.primitives import ciphers from cryptography.hazmat.primitives.ciphers import modes from cryptography.hazmat import backends from cryptography.exceptions import AlreadyFinalized -import treq +from treq import collect from twisted.internet import defer from libervia.backend.core.i18n import _ from libervia.backend.core.constants import Const as C from libervia.backend.core import exceptions from libervia.backend.tools import stream from libervia.backend.core.log import getLogger -from libervia.backend.tools.web import treq_client_no_ssl log = getLogger(__name__) @@ -103,9 +102,9 @@ class AESGCM(object): if options.get("ignore_tls_errors", False): log.warning("TLS certificate check disabled, this is highly insecure") - treq_client = treq_client_no_ssl + treq_client = client.treq_ignore_tls else: - treq_client = treq + treq_client = client.treq head_data = await treq_client.head(download_url) content_length = int(head_data.headers.getRawHeaders("content-length")[0]) @@ -124,7 +123,7 @@ class AESGCM(object): resp = await treq_client.get(download_url, unbuffered=True) if resp.code == 200: - d = treq.collect( + d = collect( resp, partial( self.on_data_download, diff --git a/libervia/backend/plugins/plugin_xep_0167/__init__.py b/libervia/backend/plugins/plugin_xep_0167/__init__.py index 954e272c..3fc9260f 100644 --- a/libervia/backend/plugins/plugin_xep_0167/__init__.py +++ b/libervia/backend/plugins/plugin_xep_0167/__init__.py @@ -31,17 +31,18 @@ from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger +from libervia.backend.models.tor import TorMode from libervia.backend.tools import xml_tools from libervia.backend.tools.common import data_format from . import mapping from ..plugin_xep_0166 import BaseApplicationHandler from .constants import ( + NS_AV_CONFERENCES, NS_JINGLE_RTP, NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_INFO, NS_JINGLE_RTP_VIDEO, - NS_AV_CONFERENCES, ) @@ -278,7 +279,13 @@ class XEP_0167(BaseApplicationHandler): @raises exceptions.DataError: If media data is invalid or duplicate content name (mid) is found. + @raise exceptions.FeatureBlocked: WebRTC is blocked for pricavy reasons. """ + if client.tor_mode != TorMode.DISABLED: + raise exceptions.FeatureBlocked( + "WebRTC is disabled when using Tor to avoid leaking IP address." + ) + metadata = self.parse_call_data(call_data) contents = self.get_contents(call_data, metadata) if not contents: @@ -415,7 +422,13 @@ class XEP_0167(BaseApplicationHandler): @param call_type: Type of media (audio or video). @return: True if the call has been accepted + @raise exceptions.FeatureBlocked: WebRTC is blocked for pricavy reasons. """ + if client.tor_mode != TorMode.DISABLED: + raise exceptions.FeatureBlocked( + "WebRTC is disabled when using Tor to avoid leaking IP address." + ) + peer_jid = session["peer_jid"] session["call_type"] = call_type diff --git a/libervia/backend/plugins/plugin_xep_0234.py b/libervia/backend/plugins/plugin_xep_0234.py index 60cea030..95835321 100644 --- a/libervia/backend/plugins/plugin_xep_0234.py +++ b/libervia/backend/plugins/plugin_xep_0234.py @@ -35,6 +35,7 @@ from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger +from libervia.backend.models.tor import TorMode from libervia.backend.tools import xml_tools from libervia.backend.tools import utils from libervia.backend.tools import stream @@ -407,6 +408,12 @@ class XEP_0234(BaseApplicationHandler): @param file_desc: description of the file @return: progress id """ + if extra and extra.get("webrtc") and client.tor_mode != TorMode.DISABLED: + raise exceptions.FeatureBlocked( + "WebRTC file transfer is disabled when using Tor to avoid leaking IP " + "address." + ) + progress_id_d = defer.Deferred() if extra is None: extra = {} @@ -778,6 +785,12 @@ class XEP_0234(BaseApplicationHandler): """parse file_elt, and handle user permission/file opening""" transport_data = content_data["transport_data"] webrtc = transport_data.get("webrtc", False) + if webrtc and client.tor_mode != TorMode.DISABLED: + raise exceptions.FeatureBlocked( + "WebRTC file transfer is disabled when using Tor to avoid leaking IP " + "address." + ) + # file may have been already accepted in preflight pre_accepted = session.get("pre_accepted", False) file_data = await self.parse_file_element(client, file_elt, file_data, given=True) diff --git a/libervia/backend/plugins/plugin_xep_0447.py b/libervia/backend/plugins/plugin_xep_0447.py index 302933ac..91ae94bb 100644 --- a/libervia/backend/plugins/plugin_xep_0447.py +++ b/libervia/backend/plugins/plugin_xep_0447.py @@ -16,19 +16,14 @@ # along with this program. If not, see . from abc import ABC, abstractmethod -from collections import namedtuple from functools import partial import mimetypes from pathlib import Path from typing import ( Any, - Callable, ClassVar, Dict, - Final, List, - Literal, - NamedTuple, Optional, Self, Tuple, @@ -37,7 +32,7 @@ from typing import ( ) from pydantic import BaseModel, Field -import treq +from treq import collect from twisted.internet import defer from twisted.words.xish import domish @@ -50,7 +45,6 @@ from libervia.backend.plugins.plugin_xep_0103 import URLData, XEP_0103 from libervia.backend.plugins.plugin_xep_0358 import JinglePub, XEP_0358 from libervia.backend.plugins.plugin_xep_0446 import FileMetadata, XEP_0446 from libervia.backend.tools import stream -from libervia.backend.tools.web import treq_client_no_ssl log = getLogger(__name__) @@ -271,9 +265,9 @@ class XEP_0447: if extra.get("ignore_tls_errors", False): log.warning("TLS certificate check disabled, this is highly insecure") - treq_client = treq_client_no_ssl + treq_client = client.treq_ignore_tls else: - treq_client = treq + treq_client = client.treq try: file_size = int(attachment["size"]) @@ -293,7 +287,7 @@ class XEP_0447: resp = await treq_client.get(download_url, unbuffered=True) if resp.code == 200: - d = treq.collect(resp, file_obj.write) + d = collect(resp, file_obj.write) d.addCallback(lambda __: file_obj.close()) else: d = defer.Deferred() diff --git a/libervia/backend/plugins/plugin_xep_0448.py b/libervia/backend/plugins/plugin_xep_0448.py index 369c4c50..7ddaf300 100644 --- a/libervia/backend/plugins/plugin_xep_0448.py +++ b/libervia/backend/plugins/plugin_xep_0448.py @@ -21,15 +21,14 @@ from functools import partial from pathlib import Path import secrets from textwrap import dedent -from typing import Any, Dict, Optional, Self, Tuple, Union, cast +from typing import Any, Self, Union, cast from cryptography.exceptions import AlreadyFinalized from cryptography.hazmat import backends from cryptography.hazmat.primitives import ciphers from cryptography.hazmat.primitives.ciphers import CipherContext, modes from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext -from pydantic import BaseModel, ValidationError -import treq +from treq import collect from twisted.internet import defer from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish @@ -43,10 +42,9 @@ from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.plugins.plugin_misc_download import DownloadPlugin from libervia.backend.plugins.plugin_xep_0103 import XEP_0103 -from libervia.backend.plugins.plugin_xep_0300 import NS_HASHES, XEP_0300, Hash +from libervia.backend.plugins.plugin_xep_0300 import XEP_0300, Hash from libervia.backend.plugins.plugin_xep_0447 import XEP_0447, Source from libervia.backend.tools import stream -from libervia.backend.tools.web import treq_client_no_ssl log = getLogger(__name__) @@ -202,9 +200,9 @@ class XEP_0448: if extra.get("ignore_tls_errors", False): log.warning("TLS certificate check disabled, this is highly insecure") - treq_client = treq_client_no_ssl + treq_client = client.treq_ignore_tls else: - treq_client = treq + treq_client = client.treq try: file_size = int(attachment["size"]) @@ -266,7 +264,7 @@ class XEP_0448: resp = await treq_client.get(download_url, unbuffered=True) if resp.code == 200: - d = treq.collect(resp, partial(decrypt_cb)) + d = collect(resp, partial(decrypt_cb)) if finalize_cb is not None: d.addCallback(lambda __: finalize_cb()) else: diff --git a/libervia/backend/tools/config.py b/libervia/backend/tools/config.py index 808cd8d0..673b9d34 100644 --- a/libervia/backend/tools/config.py +++ b/libervia/backend/tools/config.py @@ -234,6 +234,18 @@ class TLSSettings(ConfigOptions): ) +class TorSettings(ConfigOptions): + """Tor configuration options.""" + + enabled: bool = Field(default=False, description="Use Tor for connections") + + control_path: Path | None = Field( + default=None, description="Tor control Unix Socket Path" + ) + control_address: str = Field(default="127.0.0.1", description="Tor control address") + control_port: int = Field(default=9051, description="Tor control port") + + class CommonSettings(ConfigOptions): # "media_dir": "/usr/share/" + Const.APP_NAME_FILE + "/media", media_dir: DirectoryPath | None = Field( @@ -386,6 +398,7 @@ class Settings(BaseSettings): common: CommonSettings = Field(default_factory=CommonSettings) email: EmailSettings = Field(default_factory=EmailSettings) tls: TLSSettings = Field(default_factory=TLSSettings) + tor: TorSettings = Field(default_factory=TorSettings) web: WebSettings = Field(default_factory=WebSettings) plugins: dict = Field(default_factory=dict, description="Plugin specific settings.") components: dict = Field( diff --git a/libervia/frontends/bridge/dbus_bridge.py b/libervia/frontends/bridge/dbus_bridge.py index 00334a96..00f83729 100644 --- a/libervia/frontends/bridge/dbus_bridge.py +++ b/libervia/frontends/bridge/dbus_bridge.py @@ -1439,6 +1439,23 @@ class bridge: kwargs["error_handler"] = error_handler return self.db_core_iface.subscription(sub_type, entity, profile_key, **kwargs) + def tor_infos_get(self, extra, profile_key, callback=None, errback=None): + if callback is None: + error_handler = None + else: + if errback is None: + errback = log.error + error_handler = lambda err: errback(dbus_to_bridge_exception(err)) + return str( + self.db_core_iface.tor_infos_get( + extra, + profile_key, + timeout=const_TIMEOUT, + reply_handler=callback, + error_handler=error_handler, + ) + ) + def version_get(self, callback=None, errback=None): if callback is None: error_handler = None @@ -2718,6 +2735,22 @@ class AIOBridge(bridge): ) return fut + def tor_infos_get(self, extra, profile_key): + loop = asyncio.get_running_loop() + fut = loop.create_future() + reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret) + error_handler = lambda err: loop.call_soon_threadsafe( + fut.set_exception, dbus_to_bridge_exception(err) + ) + self.db_core_iface.tor_infos_get( + extra, + profile_key, + timeout=const_TIMEOUT, + reply_handler=reply_handler, + error_handler=error_handler, + ) + return fut + def version_get(self): loop = asyncio.get_running_loop() fut = loop.create_future() diff --git a/libervia/frontends/bridge/pb.py b/libervia/frontends/bridge/pb.py index c62894af..c93684df 100644 --- a/libervia/frontends/bridge/pb.py +++ b/libervia/frontends/bridge/pb.py @@ -1010,6 +1010,15 @@ class bridge(object): else: d.addErrback(self._errback, ori_errback=errback) + def tor_infos_get(self, extra, profile_key, callback=None, errback=None): + d = self.root.callRemote("tor_infos_get", extra, profile_key) + if callback is not None: + d.addCallback(callback) + if errback is None: + d.addErrback(self._generic_errback) + else: + d.addErrback(self._errback, ori_errback=errback) + def version_get(self, callback=None, errback=None): d = self.root.callRemote("version_get") if callback is not None: @@ -1489,6 +1498,11 @@ class AIOBridge(bridge): d.addErrback(self._errback) return d.asFuture(asyncio.get_event_loop()) + def tor_infos_get(self, extra, profile_key): + d = self.root.callRemote("tor_infos_get", extra, profile_key) + d.addErrback(self._errback) + return d.asFuture(asyncio.get_event_loop()) + def version_get(self): d = self.root.callRemote("version_get") d.addErrback(self._errback) diff --git a/pyproject.toml b/pyproject.toml index f09bb266..b52084f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,13 +96,39 @@ remote-control = [ conferences = [ "autobahn ~= 23.6" ] +tor = [ + "txtorcon ~= 24.8.0" +] +debugging = [ + "ipdb", "pudb" +] + +tests = [ + "pytest ~= 9.0", + "pytest-twisted", + "aiosmtpd" +] + +docs = [ + "sphinx ~= 9.0", +] + +dev = [ + "libervia-backend[debugging]", + "libervia-backend[tests]", + "libervia-backend[docs]", +] + + +# All features but dev ones all = [ "libervia-backend[SVG]", "libervia-backend[GUI]", "libervia-backend[TUI]", "libervia-backend[remote-control]", - "libervia-backend[conferences]" + "libervia-backend[conferences]", + "libervia-backend[tor]" ] [project.scripts] @@ -130,11 +156,7 @@ features = ["all"] [tool.hatch.envs.dev] dependencies = [ - "ipdb", - "pudb", - "pytest", - "pytest-twisted", - "aiosmtpd" + "libervia-backend[dev]", ] [tool.pytest.ini_options] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 15198f35..9dc59f19 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -24,6 +24,7 @@ from twisted.words.protocols.jabber import jid from libervia.backend import G from wokkel.data_form import NS_X_DATA from libervia.backend.core.main import LiberviaBackend +from libervia.backend.models.tor import TorMode from libervia.backend.tools import async_trigger as trigger from libervia.backend.core import xmpp @@ -101,4 +102,6 @@ def client(): client.server_jid = jid.JID("test.example") client.pubsub_service = jid.JID("pubsub.test.example") client.pubsub_client = AsyncMock() + client.tor = None + client.tor_mode = TorMode.DISABLED return client