Skip to content

Commit 7ed5cff

Browse files
committed
feat: tor managed services support
1 parent 21cbc31 commit 7ed5cff

File tree

6 files changed

+361
-35
lines changed

6 files changed

+361
-35
lines changed

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

POWDefense-README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# POWDefense Configuration for JoinMarket Directory Nodes
2+
3+
## Overview
4+
5+
POWDefense (Proof-of-Work Defense) is an anti-DoS protection mechanism for Tor Onion Services, introduced in Tor 0.4.8.0. It helps protect onion services from denial-of-service attacks by requiring clients to solve computational puzzles before their connection requests are prioritized.
6+
7+
**Important**: POWDefense is ONLY available for filesystem-based hidden services configured via `torrc`. It does NOT work with ephemeral services created via the ADD_ONION control port command.
8+
9+
Reference: https://onionservices.torproject.org/technology/security/pow/#configuring-an-onion-service-with-the-pow-protection
10+
11+
## Configuration
12+
13+
### 1. Configure torrc
14+
15+
Add these lines to your `torrc` file:
16+
17+
```
18+
HiddenServiceDir /var/lib/hs/dn
19+
HiddenServicePort 5222 10.3.1.36:5222
20+
HiddenServicePoWDefensesEnabled 1
21+
HiddenServicePoWQueueRate 200
22+
HiddenServicePoWQueueBurst 1000
23+
```
24+
25+
**Parameters explained:**
26+
- `HiddenServicePoWDefensesEnabled 1` - Enables POWDefense
27+
- `HiddenServicePoWQueueRate 200` - Maximum sustained rate of intro requests per second
28+
- `HiddenServicePoWQueueBurst 1000` - Maximum burst of intro requests allowed
29+
30+
### 2. Configure directory-node.cfg
31+
32+
Use the `tor-managed:` prefix in `hidden_service_dir` to signal that Tor (not JoinMarket) manages the hidden service:
33+
34+
```ini
35+
[MESSAGING:onion]
36+
type = onion
37+
socks5_host = 10.3.1.87
38+
socks5_port = 8050
39+
tor_control_host = 10.3.1.87
40+
tor_control_port = 9051
41+
onion_serving_host = 10.3.1.36
42+
onion_serving_port = 5222
43+
44+
# Use 'tor-managed:' prefix for Tor-managed hidden services with POWDefense
45+
hidden_service_dir = tor-managed:/var/lib/hs/dn
46+
directory_nodes = 1234abcd.onion:5222
47+
regtest_count = 0,0
48+
```
49+
50+
## How It Works
51+
52+
When `hidden_service_dir` starts with `tor-managed:`:
53+
54+
1. **No Control Port Connection for HS Creation**: JoinMarket does NOT connect to Tor's control port to create the hidden service via ADD_ONION. Instead, Tor manages the service based on `torrc` configuration.
55+
56+
2. **Reads Hostname from Filesystem**: JoinMarket polls for the `hostname` file in the hidden service directory created by Tor.
57+
58+
3. **Waits for Initialization**: Before starting the listener, JoinMarket waits until the message channel is fully initialized (i.e., `self.self_as_peer` exists) to avoid race conditions.
59+
60+
4. **Starts Listening**: Once initialized, JoinMarket starts listening on the configured `onion_serving_host:onion_serving_port`.
61+
62+
## Docker Configuration
63+
64+
Ensure the hidden service directory is properly mounted and accessible:
65+
66+
```yaml
67+
services:
68+
tor:
69+
volumes:
70+
- tor:/var/lib/tor
71+
- ${DN_DATA_MOUNTPOINT:-.data/directory-node}:/var/lib/hs/dn
72+
73+
dn:
74+
volumes:
75+
- ${DN_DATA_MOUNTPOINT:-.data/directory-node}:/var/lib/hs/dn
76+
depends_on:
77+
- tor
78+
```
79+
80+
## Why Not Ephemeral Services?
81+
82+
Ephemeral services (created via ADD_ONION) do NOT support POWDefense parameters. According to Tor documentation:
83+
84+
- POWDefense is configured via `torrc` using `HiddenService*` directives
85+
- The control port ADD_ONION command does not support POWDefense flags
86+
- POWDefense requires filesystem-based services with persistent directories
87+
88+
## Compatibility
89+
90+
- **Requires**: Tor 0.4.8.0 or later
91+
- **Not compatible with**: Onionbalance (as of July 2025)
92+
- **Works with**: Filesystem-based hidden services only
93+
94+
## Monitoring
95+
96+
Enable Tor logs to monitor POWDefense activity:
97+
98+
```
99+
Log notice file /var/log/tor/notices.log
100+
```
101+
102+
The logs will show:
103+
- PoW puzzle solving by clients
104+
- Queue statistics
105+
- Attack mitigation events
106+
107+
## References
108+
109+
- [Tor POWDefense Documentation](https://onionservices.torproject.org/technology/security/pow/)
110+
- [Proposal 327: A First Take at PoW Over Introduction Circuits](https://spec.torproject.org/proposals/327-pow-over-intro.html)
111+
- [Tor Manual: HiddenService Options](https://www.torproject.org/docs/tor-manual.html.en)
112+

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

0 commit comments

Comments
 (0)