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

Commit e222157

Browse files
committed
feat: tor managed services support
1 parent 5246c1c commit e222157

2 files changed

Lines changed: 84 additions & 29 deletions

File tree

src/jmbase/twisted_utils.py

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12

23
from zope.interface import implementer
34
from twisted.internet.error import ReactorNotRunning
@@ -174,44 +175,87 @@ def start_tor(self):
174175
""" This function executes the workflow
175176
of starting the hidden service and returning its hostname
176177
"""
177-
self.info_callback("Attempting to start onion service on port: {} "
178-
"...".format(self.virtual_port))
178+
self.info_callback(
179+
f"Attempting to start onion service on port: {self.virtual_port} ..."
180+
)
181+
182+
# Check if using Tor-managed mode (via torrc, not control port)
183+
if self.hidden_service_dir.startswith("tor-managed:"):
184+
self.start_tor_managed_onion()
185+
return
186+
187+
# Ephemeral or txtorcon-managed hidden service (via control port)
188+
if str(self.tor_control_host).startswith("unix:"):
189+
control_endpoint = UNIXClientEndpoint(reactor, self.tor_control_host[5:])
190+
else:
191+
control_endpoint = TCP4ClientEndpoint(
192+
reactor, self.tor_control_host, self.tor_control_port
193+
)
194+
d = txtorcon.connect(reactor, control_endpoint)
195+
179196
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)
197+
# Ephemeral hidden service (no persistence)
187198
d.addCallback(self.create_onion_ep)
188199
d.addErrback(self.setup_failed)
189-
# TODO: add errbacks to the next two calls in
190-
# the chain:
191200
d.addCallback(self.onion_listen)
192201
d.addCallback(self.print_host)
193202
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)
203+
# txtorcon-managed filesystem hidden service
204+
d.addCallback(self.create_filesystem_onion_ep)
205+
d.addErrback(self.setup_failed)
202206
d.addCallback(self.print_host_filesystem)
203207

204-
205208
def setup_failed(self, arg):
206209
# Note that actions based on this failure are deferred to callers:
207210
self.error_callback("Setup failed: " + str(arg))
208211

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

216260
def onion_listen(self, onion):
217261
# '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()

0 commit comments

Comments
 (0)