Skip to content
This repository was archived by the owner on Apr 27, 2026. It is now read-only.

Commit 0dcabb3

Browse files
committed
feat: tor managed services support
1 parent 21cbc31 commit 0dcabb3

4 files changed

Lines changed: 248 additions & 35 deletions

File tree

src/jmbase/twisted_utils.py

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import os
12

2-
from zope.interface import implementer
3+
import txtorcon
4+
from twisted.internet import defer, reactor, task
5+
from twisted.internet.endpoints import (
6+
TCP4ClientEndpoint,
7+
UNIXClientEndpoint,
8+
serverFromString,
9+
)
310
from twisted.internet.error import ReactorNotRunning
4-
from twisted.internet import reactor, defer
5-
from twisted.internet.endpoints import (TCP4ClientEndpoint,
6-
UNIXClientEndpoint, serverFromString)
711
from twisted.web.client import Agent, BrowserLikePolicyForHTTPS
8-
import txtorcon
12+
from txtorcon import TorConfig, TorControlProtocol
913
from txtorcon.web import tor_agent
10-
from txtorcon import TorControlProtocol, TorConfig
14+
from zope.interface import implementer
1115

1216
_custom_stop_reactor_is_set = False
1317
custom_stop_reactor = None
@@ -174,44 +178,87 @@ def start_tor(self):
174178
""" This function executes the workflow
175179
of starting the hidden service and returning its hostname
176180
"""
177-
self.info_callback("Attempting to start onion service on port: {} "
178-
"...".format(self.virtual_port))
181+
self.info_callback(
182+
f"Attempting to start onion service on port: {self.virtual_port} ..."
183+
)
184+
185+
# Check if using Tor-managed mode (via torrc, not control port)
186+
if self.hidden_service_dir.startswith("tor-managed:"):
187+
self.start_tor_managed_onion()
188+
return
189+
190+
# Ephemeral or txtorcon-managed hidden service (via control port)
191+
if str(self.tor_control_host).startswith("unix:"):
192+
control_endpoint = UNIXClientEndpoint(reactor, self.tor_control_host[5:])
193+
else:
194+
control_endpoint = TCP4ClientEndpoint(
195+
reactor, self.tor_control_host, self.tor_control_port
196+
)
197+
d = txtorcon.connect(reactor, control_endpoint)
198+
179199
if self.hidden_service_dir == "":
180-
if str(self.tor_control_host).startswith('unix:'):
181-
control_endpoint = UNIXClientEndpoint(reactor,
182-
self.tor_control_host[5:])
183-
else:
184-
control_endpoint = TCP4ClientEndpoint(reactor,
185-
self.tor_control_host, self.tor_control_port)
186-
d = txtorcon.connect(reactor, control_endpoint)
200+
# Ephemeral hidden service (no persistence)
187201
d.addCallback(self.create_onion_ep)
188202
d.addErrback(self.setup_failed)
189-
# TODO: add errbacks to the next two calls in
190-
# the chain:
191203
d.addCallback(self.onion_listen)
192204
d.addCallback(self.print_host)
193205
else:
194-
ep = "onion:" + str(self.virtual_port) + ":localPort="
195-
ep += str(self.serving_port)
196-
# endpoints.TCPHiddenServiceEndpoint creates version 2 by
197-
# default for backwards compat (err, txtorcon needs to update that ...)
198-
ep += ":version=3"
199-
ep += ":hiddenServiceDir="+self.hidden_service_dir
200-
onion_endpoint = serverFromString(reactor, ep)
201-
d = onion_endpoint.listen(self.proto_factory)
206+
# txtorcon-managed filesystem hidden service
207+
d.addCallback(self.create_filesystem_onion_ep)
208+
d.addErrback(self.setup_failed)
202209
d.addCallback(self.print_host_filesystem)
203210

204-
205211
def setup_failed(self, arg):
206212
# Note that actions based on this failure are deferred to callers:
207213
self.error_callback("Setup failed: " + str(arg))
208214

209215
def create_onion_ep(self, t):
210216
self.tor_connection = t
211-
portmap_string = config_to_hs_ports(self.virtual_port,
212-
self.serving_host, self.serving_port)
217+
portmap_string = config_to_hs_ports(
218+
self.virtual_port, self.serving_host, self.serving_port
219+
)
213220
return t.create_onion_service(
214-
ports=[portmap_string], private_key=txtorcon.DISCARD)
221+
ports=[portmap_string], private_key=txtorcon.DISCARD
222+
)
223+
224+
def create_filesystem_onion_ep(self, t):
225+
"""Create a persistent hidden service using txtorcon's filesystem support.
226+
Requires local Tor control port access.
227+
"""
228+
self.tor_connection = t
229+
ep = "onion:" + str(self.virtual_port) + ":localPort="
230+
ep += str(self.serving_port)
231+
ep += ":version=3"
232+
ep += ":hiddenServiceDir=" + self.hidden_service_dir
233+
onion_endpoint = serverFromString(reactor, ep)
234+
return onion_endpoint.listen(self.proto_factory)
235+
236+
def start_tor_managed_onion(self):
237+
"""
238+
For Tor-managed hidden services: read hostname, start listening.
239+
No control port connection needed.
240+
"""
241+
hs_dir = self.hidden_service_dir.removeprefix("tor-managed:")
242+
hostname_file = os.path.join(hs_dir, "hostname")
243+
244+
def check_and_start():
245+
if not os.path.exists(hostname_file):
246+
return
247+
248+
try:
249+
with open(hostname_file, "r") as f:
250+
hostname = f.read().strip()
251+
except Exception as e:
252+
self.error_callback(f"Failed to read {hostname_file}: {e}")
253+
poll_loop.stop()
254+
return
255+
256+
poll_loop.stop()
257+
self.info_callback(f"Using Tor-managed hidden service: {hostname}")
258+
self.onion_hostname_callback(hostname)
259+
260+
poll_loop = task.LoopingCall(check_and_start)
261+
poll_loop.start(0.5)
215262

216263
def onion_listen(self, onion):
217264
# 'onion' arg is the created EphemeralOnionService object;

src/jmdaemon/onionmc.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from twisted.internet import reactor, task, protocol
99
from twisted.protocols import basic
1010
from twisted.application.internet import ClientService
11-
from twisted.internet.endpoints import TCP4ClientEndpoint
11+
from twisted.internet.endpoints import serverFromString, TCP4ClientEndpoint
1212
from twisted.internet.address import IPv4Address, IPv6Address
1313
from txtorcon.socks import (TorSocksEndpoint, HostUnreachableError,
1414
SocksError, GeneralServerFailureError)
@@ -697,9 +697,12 @@ def __init__(self,
697697
# it'll fire the `setup_error_callback`.
698698
self.hs.start_tor()
699699

700-
# This will serve as our unique identifier, indicating
701-
# that we are ready to communicate (in both directions) over Tor.
702-
self.onion_hostname = None
700+
# For tor-managed services, the hostname is set synchronously by start_tor()
701+
# For ephemeral services, we need to wait for the callback
702+
if not self.hidden_service_dir.startswith("tor-managed:"):
703+
# This will serve as our unique identifier, indicating
704+
# that we are ready to communicate (in both directions) over Tor.
705+
self.onion_hostname = None
703706
else:
704707
# dummy 'hostname' to indicate we can start running immediately:
705708
self.onion_hostname = NOT_SERVING_ONION_HOSTNAME
@@ -884,7 +887,7 @@ def connect_to_directories(self) -> None:
884887
if self.genesis_node:
885888
# we are a directory and we have no directory peers;
886889
# just start.
887-
self.on_welcome(self)
890+
self._start_listener()
888891
return
889892
# the remaining code is only executed by non-directories:
890893
for p in self.peers:
@@ -901,6 +904,13 @@ def connect_to_directories(self) -> None:
901904
self.wait_for_directories)
902905
self.wait_for_directories_loop.start(2.0)
903906

907+
def _start_listener(self) -> None:
908+
serverstring = f"tcp:{self.onion_serving_port}:interface={self.onion_serving_host}"
909+
onion_endpoint = serverFromString(reactor, serverstring)
910+
d = onion_endpoint.listen(self.proto_factory)
911+
d.addCallback(self.on_welcome)
912+
d.addErrback(lambda f: self.setup_error_callback(f"Listen failed: {f}"))
913+
904914
def handshake_as_client(self, peer: OnionPeer) -> None:
905915
assert peer.status() == PEER_STATUS_CONNECTED
906916
if self.self_as_peer.directory:
@@ -1461,7 +1471,8 @@ def wait_for_directories(self) -> None:
14611471
# Note that even if the preceding (max) 50 seconds failed to
14621472
# connect all our configured dps, we will keep trying and they
14631473
# can still be used.
1464-
if not self.on_welcome_sent:
1474+
# For genesis nodes, on_welcome is called after the listener starts
1475+
if not self.on_welcome_sent and not self.genesis_node:
14651476
self.on_welcome(self)
14661477
self.on_welcome_sent = True
14671478
self.wait_for_directories_loop.stop()

test/jmbase/test_twisted_utils.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
5+
from jmbase.twisted_utils import JMHiddenService
6+
7+
8+
def mock_hs(hidden_service_dir: str = "") -> JMHiddenService:
9+
return JMHiddenService(
10+
Mock(),
11+
Mock(),
12+
Mock(),
13+
Mock(),
14+
"127.0.0.1",
15+
9051,
16+
"127.0.0.1",
17+
8080,
18+
80,
19+
None,
20+
hidden_service_dir,
21+
)
22+
23+
24+
class TestTorManagedHiddenService:
25+
@pytest.mark.parametrize(
26+
"hidden_service_dir,expect_managed,expect_connect",
27+
[
28+
("tor-managed:/path/to/dir", True, False),
29+
("/normal/path", False, True),
30+
],
31+
)
32+
def test_hidden_service_dir_detection(
33+
self, hidden_service_dir, expect_managed, expect_connect
34+
):
35+
with (
36+
patch.object(JMHiddenService, "start_tor_managed_onion") as mock_managed,
37+
patch("jmbase.twisted_utils.txtorcon.connect") as mock_connect,
38+
):
39+
hs = mock_hs(hidden_service_dir)
40+
41+
hs.start_tor()
42+
43+
if expect_managed:
44+
mock_managed.assert_called_once()
45+
mock_connect.assert_not_called()
46+
else:
47+
mock_managed.assert_not_called()
48+
mock_connect.assert_called_once()
49+
50+
def test_ephemeral_service_creation(self):
51+
with patch("jmbase.twisted_utils.txtorcon") as mock_txtorcon:
52+
mock_t = Mock()
53+
mock_t.create_onion_service.return_value = Mock()
54+
55+
hs = mock_hs()
56+
hs.tor_connection = mock_t
57+
hs.virtual_port = 80
58+
hs.serving_host = "127.0.0.1"
59+
hs.serving_port = 8080
60+
61+
hs.create_onion_ep(mock_t)
62+
63+
mock_t.create_onion_service.assert_called_once_with(
64+
ports=["80 127.0.0.1:8080"], private_key=mock_txtorcon.DISCARD
65+
)

test/jmdaemon/test_onionmc.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
5+
from jmdaemon.onionmc import OnionMessageChannel
6+
7+
8+
@pytest.fixture
9+
def configdata():
10+
return {
11+
"btcnet": "mainnet",
12+
"tor_control_host": "127.0.0.1",
13+
"tor_control_port": 9051,
14+
"onion_serving_host": "127.0.0.1",
15+
"serving": False,
16+
"socks5_host": "127.0.0.1",
17+
"socks5_port": 9050,
18+
"directory_nodes": "",
19+
"passive": False,
20+
}
21+
22+
23+
class TestOnionMessageChannelListener:
24+
def test_start_listener_creates_tcp_endpoint(self, configdata):
25+
with (
26+
patch("jmdaemon.onionmc.reactor") as mock_reactor,
27+
patch("jmdaemon.onionmc.serverFromString") as mock_server_from_string,
28+
):
29+
mock_deferred = Mock()
30+
mock_endpoint = Mock()
31+
mock_endpoint.listen.return_value = mock_deferred
32+
mock_server_from_string.return_value = mock_endpoint
33+
34+
mc = OnionMessageChannel(configdata)
35+
mc.onion_serving_host = "127.0.0.1"
36+
mc.onion_serving_port = 8080
37+
mc.proto_factory = Mock()
38+
mc.on_welcome = Mock()
39+
mc.setup_error_callback = Mock()
40+
41+
mc._start_listener()
42+
43+
expected_serverstring = "tcp:8080:interface=127.0.0.1"
44+
mock_server_from_string.assert_called_once_with(
45+
mock_reactor, expected_serverstring
46+
)
47+
48+
mock_endpoint.listen.assert_called_once_with(mc.proto_factory)
49+
50+
mock_deferred.addCallback.assert_called_once_with(mc.on_welcome)
51+
mock_deferred.addErrback.assert_called_once()
52+
53+
errback_callback = mock_deferred.addErrback.call_args[0][0]
54+
test_failure = Exception("Test error")
55+
errback_callback(test_failure)
56+
57+
mc.setup_error_callback.assert_called_once_with("Listen failed: Test error")
58+
59+
@pytest.mark.parametrize(
60+
"host,port",
61+
[
62+
("192.168.1.1", 9000),
63+
("localhost", 1234),
64+
("0.0.0.0", 80),
65+
],
66+
)
67+
def test_start_listener_different_ports_and_hosts(self, configdata, host, port):
68+
with (
69+
patch("jmdaemon.onionmc.reactor") as mock_reactor,
70+
patch("jmdaemon.onionmc.serverFromString") as mock_server_from_string,
71+
):
72+
mock_endpoint = Mock()
73+
mock_server_from_string.return_value = mock_endpoint
74+
75+
configdata["onion_serving_host"] = host
76+
configdata["onion_serving_port"] = port
77+
78+
mc = OnionMessageChannel(configdata)
79+
mc.onion_serving_host = host
80+
mc.onion_serving_port = port
81+
mc.proto_factory = Mock()
82+
mc.on_welcome = Mock()
83+
mc.setup_error_callback = Mock()
84+
85+
mc._start_listener()
86+
87+
expected_serverstring = f"tcp:{port}:interface={host}"
88+
mock_server_from_string.assert_called_once_with(
89+
mock_reactor, expected_serverstring
90+
)

0 commit comments

Comments
 (0)