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