Skip to content

Commit fd550ee

Browse files
Adam GibsonAdamISZ
Adam Gibson
authored andcommitted
Onion-based message channels with directory nodes
Joinmarket bots run their own onion services allowing inbound connections. Both takers and makers connect to other makers at the mentioned onion services, over Tor. Directory nodes run persistent onion services allowing peers to find other (maker) peers to connect to, and also forwarding messages where necessary. This is implemented as an alternative to IRC, i.e. a new implementation of the abstract class MessageChannel, in onionmc.py. Note that using both this *and* IRC servers is supported; Joinmarket supports multiple, redundant different communication methods, simultaneously. Messaging is done with a derived class of twisted's LineReceiver, and there is an additional layer of syntax, similar to but not the same as the IRC syntax for ensuring that messages are passed with the same J5.. nick as is used on IRC. This allows us to keep the message signing logic the same as before. As well as Joinmarket line messages, we use additional control messages to communicate peer lists, and to manage connections. Peers which send messages not conforming to the syntax are dropped. See JoinMarket-Org/JoinMarket-Docs#12 for documentation of the syntax. Connections to directory nodes are robust as for IRC servers, in that we use a ReconnectingClientFactory to keep trying to re-establish broken connections with exponential backoff. Connections to maker peers do not require this feature, as they will often disconnect in normal operation. Multiple directory nodes can and should be configured by bots.
1 parent bb8cd00 commit fd550ee

17 files changed

+1945
-94
lines changed

docs/onion-message-channels.md

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# HOW TO SETUP ONION MESSAGE CHANNELS IN JOINMARKET
2+
3+
### Contents
4+
5+
1. [Overview](#overview)
6+
7+
2. [Testing, configuring for signet](#testing)
8+
9+
4. [Directory nodes](#directory)
10+
11+
<a name="overview" />
12+
13+
## Overview
14+
15+
This is a new way for Joinmarket bots to communicate, namely by serving and connecting to Tor onion services. This does not
16+
introduce any new requirements to your Joinmarket installation, technically, because the use of Payjoin already required the need
17+
to service such onion services, and connecting to IRC used a SOCKS5 proxy (by default, and used by almost all users) over Tor to
18+
a remote onion service.
19+
20+
The purpose of this new type of message channel is as follows:
21+
22+
* less reliance on any service external to Joinmarket
23+
* most of the transaction negotiation will be happening directly peer to peer, not passed over a central server (
24+
albeit it was and remains E2E encrypted data, in either case)
25+
* the above can lead to better scalability at large numbers
26+
* a substantial increase in the speed of transaction negotiation; this is mostly related to the throttling of high bursts of traffic on IRC
27+
28+
The configuration for a user is simple; in their `joinmarket.cfg` they will add a messaging section like this:
29+
30+
```
31+
[MESSAGING:onion1]
32+
type = onion
33+
onion_serving_port = 8082
34+
# This is a comma separated list (comma can be omitted if only one item).
35+
# Each item has format host:port
36+
directory_nodes = rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:80
37+
```
38+
39+
Here, I have deliberately omitted the several other settings in this section which will almost always be fine as default;
40+
see `jmclient/jmclient/configure.py` for what those defaults are, and the extensive comments explaining.
41+
42+
The main point is the list of **directory nodes** (the one shown here is one being run on signet, right now), which will
43+
be comma separated if multiple directory nodes are configured (we expect there will be 2 or 3 as a normal situation).
44+
The `onion_serving_port` is on which port on the local machine the onion service is served.
45+
The `type` field must always be `onion` in this case, and distinguishes it from IRC message channels and others.
46+
47+
### Can/should I still run IRC message channels?
48+
49+
In short, yes.
50+
51+
### Do I need to configure Tor, and if so, how?
52+
53+
These message channels use both outbound and inbound connections to onion services (or "hidden services").
54+
55+
As previously mentioned, both of these features were already in use in Joinmarket. If you never served an
56+
onion service before, it should work fine as long as you have the Tor service running in the background,
57+
and the default control port 9051 (if not, change that value in the `joinmarket.cfg`, see above.
58+
59+
#### Why not use Lightning based onions?
60+
61+
(*Feel free to skip this section if you don't know what "Lightning based onions" refers to!*). The reason this architecture is
62+
proposed as an alternative to the previously suggested Lightning-node-based network (see
63+
[this PR](https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1000)), is mostly that:
64+
65+
* the latter has a bunch of extra installation and maintenance dependencies (just one example: pyln-client requires coincurve, which we just
66+
removed)
67+
* the latter requires establishing a new node "identity" which can be refreshed, but that creates more concern
68+
* longer term ideas to integrate Lightning payments to the coinjoin workflow (and vice versa!) are not realizable yet
69+
* using multi-hop onion messaging in the LN network itself is also a way off, and a bit problematic
70+
71+
So the short version is: the Lightning based alternative is certainly feasible, but has a lot more baggage that can't really be justified
72+
unless we're actually using it for something.
73+
74+
75+
<a name="testing" />
76+
77+
## Testing, and configuring for signet.
78+
79+
This testing section focuses on signet since that will be the less troublesome way of getting involved in tests for
80+
the non-hardcore JM developer :)
81+
82+
(For the latter, please use the regtest setup by running `test/e2e-coinjoin-test.py` under `pytest`,
83+
and pay attention to the settings in `regtest_joinmarket.cfg`.)
84+
85+
There is no separate/special configuration for signet other than the configuration that is already needed for running
86+
Joinmarket against a signet backend (so e.g. RPC port of 38332).
87+
88+
Add the `[MESSAGING:onion1]` message channel section to your `joinmarket.cfg`, as listed above, including the
89+
signet directory node listed above (rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:80), and,
90+
for the simplest test, remove the other `[MESSAGING:*]` sections that you have.
91+
92+
Then just make sure your bot has some signet coins and try running as maker or taker or both.
93+
94+
<a name="directory" />
95+
96+
## Directory nodes
97+
98+
**This last section is for people with a lot of technical knowledge in this area,
99+
who would like to help by running a directory node. You can ignore it if that does not apply.**.
100+
101+
This requires a long running bot. It should be on a server you can keep running permanently, so perhaps a VPS,
102+
but in any case, very high uptime. For reliability it also makes sense to configure to run as a systemd service.
103+
104+
A note: in this early stage, the usage of Lightning is only really network-layer stuff, and the usage of bitcoin, is none; feel free to add elements that remove any need for a backend bitcoin blockchain, but beware: future upgrades *could* mean that the directory node really does need the bitcoin backend.
105+
106+
#### Joinmarket-specific configuration
107+
108+
Add `hidden_service_dir` to your `[MESSAGING:onion1]` with a directory accessible to your user. You may want to lock this down
109+
a bit!
110+
The point to understand is: Joinmarket's `jmbase.JMHiddenService` will, if configured with a non-empty `hidden_service_dir`
111+
field, actually start an *independent* instance of Tor specifically for serving this, under the current user.
112+
(our tor interface library `txtorcon` needs read access to the Tor HS dir, so it's troublesome to do this another way).
113+
114+
##### Question: How to configure the `directory-nodes` list in our `joinmarket.cfg` for this directory node bot?
115+
116+
Answer: **you must only enter your own node in this list!** (otherwise you may find your bot infinitely rebroadcasting messages).
117+
118+
119+
#### Suggested setup of a service:
120+
121+
You will need two components: bitcoind, and Joinmarket itself, which you can run as a yg.
122+
Since this task is going to be attempted by someone with significant technical knowledge,
123+
only an outline is provided here; several details will need to be filled in.
124+
Here is a sketch of how the systemd service files can be set up for signet:
125+
126+
If someone wants to put together a docker setup of this for a more "one-click install", that would be great.
127+
128+
1. bitcoin-signet.service
129+
130+
```
131+
[Unit]
132+
Description=bitcoind signet
133+
After=network-online.target
134+
Wants=network-online.target
135+
136+
[Service]
137+
Type=simple
138+
ExecStart=/usr/local/bin/bitcoind -signet
139+
User=user
140+
141+
[Install]
142+
WantedBy=multi-user.target
143+
```
144+
145+
This is deliberately a super-basic setup (see above). Don't forget to setup your `bitcoin.conf` as usual,
146+
for the bitcoin user, and make it match (specifically in terms of RPC) what you set up for Lightning below.
147+
148+
149+
2.
150+
151+
```
152+
[Unit]
153+
Description=joinmarket directory node on signet
154+
Requires=bitcoin-signet.service
155+
After=bitcoin-signet.service
156+
157+
[Service]
158+
Type=simple
159+
ExecStart=/bin/bash -c 'cd /path/to/joinmarket-clientserver && source jmvenv/bin/activate && cd scripts && echo -n "password" | python yg-privacyenhanced.py --wallet-password-stdin --datadir=/custom/joinmarket-datadir some-signet-wallet.jmdat'
160+
User=user
161+
162+
[Install]
163+
WantedBy=multi-user.target
164+
```
165+
166+
To state the obvious, the idea here is that this second service will run the JM directory node and have a dependency on the previous one,
167+
to ensure they start up in the correct order.
168+
169+
Re: password echo, obviously this kind of password entry is bad;
170+
for now we needn't worry as these nodes don't need to carry any real coins (and it's better they don't!).
171+
Later we may need to change that (though of course you can use standard measures to protect the box).
172+
173+
TODO: add some material on network hardening/firewalls here, I guess.

jmbase/jmbase/twisted_utils.py

+56-21
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,23 @@ def config_to_hs_ports(virtual_port, host, port):
128128
class JMHiddenService(object):
129129
""" Wrapper class around the actions needed to
130130
create and serve on a hidden service; an object of
131-
type Resource must be provided in the constructor,
132-
which does the HTTP serving actions (GET, POST serving).
131+
type either Resource or server.ProtocolFactory must
132+
be provided in the constructor, which does the HTTP
133+
(GET, POST) or other protocol serving actions.
133134
"""
134-
def __init__(self, resource, info_callback, error_callback,
135-
onion_hostname_callback, tor_control_host,
135+
def __init__(self, proto_factory_or_resource, info_callback,
136+
error_callback, onion_hostname_callback, tor_control_host,
136137
tor_control_port, serving_host, serving_port,
137-
virtual_port = None,
138-
shutdown_callback = None):
139-
self.site = Site(resource)
140-
self.site.displayTracebacks = False
138+
virtual_port=None,
139+
shutdown_callback=None,
140+
hidden_service_dir=""):
141+
if isinstance(proto_factory_or_resource, Resource):
142+
# TODO bad naming, in this case it doesn't start
143+
# out as a protocol factory; a Site is one, a Resource isn't.
144+
self.proto_factory = Site(proto_factory_or_resource)
145+
self.proto_factory.displayTracebacks = False
146+
else:
147+
self.proto_factory = proto_factory_or_resource
141148
self.info_callback = info_callback
142149
self.error_callback = error_callback
143150
# this has a separate callback for convenience, it should
@@ -155,26 +162,45 @@ def __init__(self, resource, info_callback, error_callback,
155162
# config object, so no default here:
156163
self.serving_host = serving_host
157164
self.serving_port = serving_port
165+
# this is used to serve an onion from the filesystem,
166+
# NB: Because of how txtorcon is set up, this option
167+
# uses a *separate tor instance* owned by the owner of
168+
# this script (because txtorcon needs to read the
169+
# HS dir), whereas if this option is "", we set up
170+
# an ephemeral HS on the global or pre-existing tor.
171+
self.hidden_service_dir = hidden_service_dir
158172

159173
def start_tor(self):
160174
""" This function executes the workflow
161175
of starting the hidden service and returning its hostname
162176
"""
163177
self.info_callback("Attempting to start onion service on port: {} "
164178
"...".format(self.virtual_port))
165-
if str(self.tor_control_host).startswith('unix:'):
166-
control_endpoint = UNIXClientEndpoint(reactor,
167-
self.tor_control_host[5:])
179+
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)
187+
d.addCallback(self.create_onion_ep)
188+
d.addErrback(self.setup_failed)
189+
# TODO: add errbacks to the next two calls in
190+
# the chain:
191+
d.addCallback(self.onion_listen)
192+
d.addCallback(self.print_host)
168193
else:
169-
control_endpoint = TCP4ClientEndpoint(reactor,
170-
self.tor_control_host, self.tor_control_port)
171-
d = txtorcon.connect(reactor, control_endpoint)
172-
d.addCallback(self.create_onion_ep)
173-
d.addErrback(self.setup_failed)
174-
# TODO: add errbacks to the next two calls in
175-
# the chain:
176-
d.addCallback(self.onion_listen)
177-
d.addCallback(self.print_host)
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)
202+
d.addCallback(self.print_host_filesystem)
203+
178204

179205
def setup_failed(self, arg):
180206
# Note that actions based on this failure are deferred to callers:
@@ -195,7 +221,8 @@ def onion_listen(self, onion):
195221
serverstring = "tcp:{}:interface={}".format(self.serving_port,
196222
self.serving_host)
197223
onion_endpoint = serverFromString(reactor, serverstring)
198-
return onion_endpoint.listen(self.site)
224+
print("created the onion endpoint, now calling listen")
225+
return onion_endpoint.listen(self.proto_factory)
199226

200227
def print_host(self, ep):
201228
""" Callback fired once the HS is available
@@ -204,6 +231,14 @@ def print_host(self, ep):
204231
"""
205232
self.onion_hostname_callback(self.onion.hostname)
206233

234+
def print_host_filesystem(self, port):
235+
""" As above but needed to respect slightly different
236+
callback chain for this case (where we start our own tor
237+
instance for the filesystem-based onion).
238+
"""
239+
self.onion = port.onion_service
240+
self.onion_hostname_callback(self.onion.hostname)
241+
207242
def shutdown(self):
208243
self.tor_connection.protocol.transport.loseConnection()
209244
self.info_callback("Hidden service shutdown complete")

jmclient/jmclient/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, detect_script_type)
2525
from .configure import (load_test_config, process_shutdown,
2626
load_program_config, jm_single, get_network, update_persist_config,
27-
validate_address, is_burn_destination, get_irc_mchannels,
27+
validate_address, is_burn_destination, get_mchannels,
2828
get_blockchain_interface_instance, set_config, is_segwit_mode,
2929
is_native_segwit_mode, JMPluginService, get_interest_rate, get_bondless_makers_allowance)
3030
from .blockchaininterface import (BlockchainInterface,

jmclient/jmclient/client_protocol.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import sys
1616
from jmbase import (get_log, EXIT_FAILURE, hextobin, bintohex,
1717
utxo_to_utxostr, bdict_sdict_convert)
18-
from jmclient import (jm_single, get_irc_mchannels,
18+
from jmclient import (jm_single, get_mchannels,
1919
RegtestBitcoinCoreInterface,
2020
SNICKERReceiver, process_shutdown)
2121
import jmbitcoin as btc
@@ -434,7 +434,7 @@ def clientStart(self):
434434
"blockchain_source")
435435
#needed only for channel naming convention
436436
network = jm_single().config.get("BLOCKCHAIN", "network")
437-
irc_configs = get_irc_mchannels()
437+
irc_configs = self.factory.get_mchannels()
438438
#only here because Init message uses this field; not used by makers TODO
439439
minmakers = jm_single().config.getint("POLICY", "minimum_makers")
440440
maker_timeout_sec = jm_single().maker_timeout_sec
@@ -601,7 +601,7 @@ def clientStart(self):
601601
"blockchain_source")
602602
#needed only for channel naming convention
603603
network = jm_single().config.get("BLOCKCHAIN", "network")
604-
irc_configs = get_irc_mchannels()
604+
irc_configs = self.factory.get_mchannels()
605605
minmakers = jm_single().config.getint("POLICY", "minimum_makers")
606606
maker_timeout_sec = jm_single().maker_timeout_sec
607607

@@ -795,6 +795,14 @@ def getClient(self):
795795
def buildProtocol(self, addr):
796796
return self.protocol(self, self.client)
797797

798+
def get_mchannels(self):
799+
""" A transparent wrapper that allows override,
800+
so that a script can return a customised set of
801+
message channel configs; currently used for testing
802+
multiple bots on regtest.
803+
"""
804+
return get_mchannels()
805+
798806
def start_reactor(host, port, factory=None, snickerfactory=None,
799807
bip78=False, jm_coinjoin=True, ish=True,
800808
daemon=False, rs=True, gui=False): #pragma: no cover

0 commit comments

Comments
 (0)