Skip to content

Commit 6d97bec

Browse files
committed
feat: tor managed services support
1 parent 21cbc31 commit 6d97bec

File tree

8 files changed

+304
-39
lines changed

8 files changed

+304
-39
lines changed

conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import subprocess
44
from shlex import split
55
from time import sleep
6+
from twisted.internet import reactor
67
from typing import Any, Tuple
78

89
import pytest
@@ -172,3 +173,19 @@ def setup_regtest_bitcoind(pytestconfig):
172173
local_command(stop_cmd)
173174
# note, it is better to clean out ~/.bitcoin/regtest but too
174175
# dangerous to automate it here perhaps
176+
177+
178+
@pytest.fixture(autouse=True)
179+
def reset_reactor_state(request):
180+
"""Reset reactor _startedBefore flag after twisted.trial tests.
181+
182+
twisted.trial stops the reactor after each test, which marks it as
183+
_startedBefore=True, preventing subsequent tests from running the reactor.
184+
This fixture resets the flag to allow other tests to use the reactor.
185+
"""
186+
187+
def reset_flag():
188+
if hasattr(reactor, "_startedBefore") and reactor._startedBefore:
189+
if not reactor.running:
190+
reactor._startedBefore = False
191+
request.addfinalizer(reset_flag)

docs/onion-message-channels.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ onion_serving_port = 8080
5959
# but NOT TO BE USED by non-directory nodes (which is you, unless
6060
# you know otherwise!), as it will greatly degrade your privacy.
6161
# (note the default is no value, don't replace it with "").
62+
# Use tor-managed: prefix to use Tor-managed hidden services.
6263
hidden_service_dir =
6364
#
6465
# This is a comma separated list (comma can be omitted if only one item).
@@ -155,9 +156,11 @@ Add a non-empty `hidden_service_dir` entry to your `[MESSAGING:onion]` with a di
155156

156157
The hostname for your onion service will not change and will be stored permanently in that directory.
157158

158-
The point to understand is: Joinmarket's `jmbase.JMHiddenService` will, if configured with a non-empty `hidden_service_dir`
159-
field, actually start an *independent* instance of Tor specifically for serving this, under the current user.
160-
(our Tor interface library `txtorcon` needs read access to the Tor HS dir, so it's troublesome to do this another way).
159+
There are two ways to configure a persistent hidden service:
160+
161+
1. **txtorcon-managed** (default when `hidden_service_dir` is set to a path): Joinmarket's `jmbase.JMHiddenService` will manage the hidden service via the Tor control port. This requires control port access and will start an *independent* instance of Tor specifically for serving this, under the current user. (our Tor interface library `txtorcon` needs read access to the Tor HS dir, so it's troublesome to do this another way).
162+
163+
2. **Tor-managed** (when `hidden_service_dir` is prefixed with `tor-managed:`): Tor manages the hidden service via its `torrc` configuration file. JoinMarket reads the hostname from the `hostname` file in the specified directory. This mode does not require Tor control port access. See [Tor configuration documentation](./tor.md) for setup instructions.
161164

162165
#### Question: How to configure the `directory-nodes` list in our `joinmarket.cfg` for this directory node bot?
163166

docs/tor.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,37 @@ sudo service tor start
8383
```
8484

8585
Once this is done, you should be able to start the yieldgenerator successfully.
86+
87+
#### Tor-managed hidden services
88+
89+
As an alternative to using the Tor control port, you can configure Tor to manage the hidden service directly via its configuration file (`torrc`). This approach is useful when:
90+
91+
- You want Tor to fully manage the hidden service lifecycle
92+
- You don't want to grant control port access to JoinMarket
93+
- You're running Tor as a system service and prefer centralized configuration
94+
95+
To use this mode:
96+
97+
1. Configure the hidden service in Tor's `torrc` file (typically `/etc/tor/torrc`):
98+
99+
```ini
100+
HiddenServiceDir /var/lib/tor/joinmarket_hidden_service
101+
HiddenServicePort 5222 127.0.0.1:8080
102+
```
103+
104+
2. Set appropriate permissions
105+
3. Restart To
106+
4. Configure JoinMarket to use the Tor-managed service by setting `hidden_service_dir` in your `joinmarket.cfg`:
107+
108+
```ini
109+
hidden_service_dir = tor-managed:/var/lib/tor/joinmarket_hidden_service
110+
```
111+
112+
Note the `tor-managed:` prefix, which tells JoinMarket to read the hostname from the `hostname` file in that directory rather than managing the service via the control port.
113+
114+
##### Important notes
115+
116+
- The directory path in `hidden_service_dir` must match exactly what's configured in `torrc`
117+
- JoinMarket will read the hostname from the `hostname` file; make sure Tor has created it
118+
- No control port configuration is needed for this mode (though you may still need it for other features)
119+
- The hidden service directory must be readable by the user running JoinMarket (or the `hostname` file at minimum)

src/jmbase/twisted_utils.py

Lines changed: 74 additions & 30 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
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
@@ -170,48 +174,86 @@ def __init__(self, proto_factory_or_resource, info_callback,
170174
# an ephemeral HS on the global or pre-existing tor.
171175
self.hidden_service_dir = hidden_service_dir
172176

177+
self.tor_connection = None
178+
173179
def start_tor(self):
174180
""" This function executes the workflow
175181
of starting the hidden service and returning its hostname
176182
"""
177-
self.info_callback("Attempting to start onion service on port: {} "
178-
"...".format(self.virtual_port))
183+
self.info_callback(
184+
f"Attempting to start onion service on port: {self.virtual_port} ..."
185+
)
186+
187+
# Check if using Tor-managed mode (via torrc, not control port)
188+
if self.hidden_service_dir.startswith("tor-managed:"):
189+
self.start_tor_managed_onion()
190+
return
191+
192+
# Ephemeral or txtorcon-managed hidden service (via control port)
193+
if str(self.tor_control_host).startswith("unix:"):
194+
control_endpoint = UNIXClientEndpoint(reactor, self.tor_control_host[5:])
195+
else:
196+
control_endpoint = TCP4ClientEndpoint(
197+
reactor, self.tor_control_host, self.tor_control_port
198+
)
199+
d = txtorcon.connect(reactor, control_endpoint)
200+
179201
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)
202+
# Ephemeral hidden service (no persistence)
187203
d.addCallback(self.create_onion_ep)
188204
d.addErrback(self.setup_failed)
189-
# TODO: add errbacks to the next two calls in
190-
# the chain:
191205
d.addCallback(self.onion_listen)
192206
d.addCallback(self.print_host)
193207
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)
208+
# txtorcon-managed filesystem hidden service
209+
d.addCallback(self.create_filesystem_onion_ep)
210+
d.addErrback(self.setup_failed)
202211
d.addCallback(self.print_host_filesystem)
203212

204-
205213
def setup_failed(self, arg):
206214
# Note that actions based on this failure are deferred to callers:
207215
self.error_callback("Setup failed: " + str(arg))
208216

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

216258
def onion_listen(self, onion):
217259
# 'onion' arg is the created EphemeralOnionService object;
@@ -240,11 +282,13 @@ def print_host_filesystem(self, port):
240282
self.onion_hostname_callback(self.onion.hostname)
241283

242284
def shutdown(self):
243-
self.tor_connection.protocol.transport.loseConnection()
285+
if self.tor_connection:
286+
self.tor_connection.protocol.transport.loseConnection()
244287
self.info_callback("Hidden service shutdown complete")
245288
if self.shutdown_callback:
246289
self.shutdown_callback()
247290

291+
248292
class JMHTTPResource(Resource):
249293
""" Object acting as HTTP serving resource
250294
"""

src/jmclient/configure.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def jm_single() -> AttributeDict:
172172
# but NOT TO BE USED by non-directory nodes (which is you, unless
173173
# you know otherwise!), as it will greatly degrade your privacy.
174174
# (note the default is no value, don't replace it with "").
175+
# Use tor-managed: prefix to use Tor-managed hidden services.
175176
hidden_service_dir =
176177
#
177178
# This is a comma separated list (comma can be omitted if only one item).

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+
)

0 commit comments

Comments
 (0)