This repository was archived by the owner on Apr 27, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 198
Expand file tree
/
Copy pathonionmc.py
More file actions
1539 lines (1417 loc) · 69.6 KB
/
Copy pathonionmc.py
File metadata and controls
1539 lines (1417 loc) · 69.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from jmdaemon.message_channel import MessageChannel
from jmdaemon.protocol import COMMAND_PREFIX, JM_VERSION
from jmbase import get_log, JM_APP_NAME, JMHiddenService, stop_reactor
import json
import copy
import random
from typing import Callable, Union, Tuple, List
from twisted.internet import reactor, task, protocol
from twisted.protocols import basic
from twisted.application.internet import ClientService
from twisted.internet.endpoints import serverFromString, TCP4ClientEndpoint
from twisted.internet.address import IPv4Address, IPv6Address
from txtorcon.socks import (TorSocksEndpoint, HostUnreachableError,
SocksError, GeneralServerFailureError)
log = get_log()
NOT_SERVING_ONION_HOSTNAME = "NOT-SERVING-ONION"
# LongLivedPort
ONION_VIRTUAL_PORT = 5222
# How many seconds to wait before treating an onion
# as unreachable
CONNECT_TO_ONION_TIMEOUT = 60
def location_tuple_to_str(t: Tuple[str, int]) -> str:
return f"{t[0]}:{t[1]}"
def network_addr_to_string(location: Union[IPv4Address, IPv4Address]) -> str:
if isinstance(location, (IPv4Address, IPv6Address)):
host = location.host
port = location.port
else:
# TODO handle other addr types
assert False
return location_tuple_to_str((host, port))
# module-level var to control whether we use Tor or not
# (specifically for tests)
testing_mode = False
def set_testing_mode(configdata: dict) -> None:
""" Toggles testing mode which enables non-Tor
network setup:
"""
global testing_mode
if "regtest_count" not in configdata:
testing_mode = False
return
try:
s, e = [int(x) for x in configdata["regtest_count"].split(",")]
except Exception as e:
log.info("Failed to get regtest count settings, error: {}".format(repr(e)))
testing_mode = False
return
if s == e == 0:
testing_mode = False
return
testing_mode = True
"""
Messaging protocol (which wraps the underlying Joinmarket
messaging protocol) used here is documented in:
Joinmarket-Docs/onion-messaging.md
"""
LOCAL_CONTROL_MESSAGE_TYPES = {"connect": 785, "disconnect": 787, "connect-in": 797}
CONTROL_MESSAGE_TYPES = {"peerlist": 789, "getpeerlist": 791,
"handshake": 793, "dn-handshake": 795,
"ping": 797, "pong": 799, "disconnect": 801}
JM_MESSAGE_TYPES = {"privmsg": 685, "pubmsg": 687}
# Used for some control message construction, as detailed below.
NICK_PEERLOCATOR_SEPARATOR = ";"
# location_string, nick and network must be set before sending,
# otherwise invalid:
client_handshake_json = {"app-name": JM_APP_NAME,
"directory": False,
"location-string": "",
"proto-ver": JM_VERSION,
"features": {},
"nick": "",
"network": ""
}
# default acceptance false; code must switch it on:
server_handshake_json = {"app-name": JM_APP_NAME,
"directory": True,
"proto-ver-min": JM_VERSION,
"proto-ver-max": JM_VERSION,
"features": {},
"accepted": False,
"nick": "",
"network": "",
"motd": "Default MOTD, replace with information for the directory."
}
# states that keep track of relationship to a peer
PEER_STATUS_UNCONNECTED, PEER_STATUS_CONNECTED, PEER_STATUS_HANDSHAKED, \
PEER_STATUS_DISCONNECTED = range(4)
class OnionPeerError(Exception):
pass
class OnionPeerDirectoryWithoutHostError(OnionPeerError):
pass
class OnionPeerConnectionError(OnionPeerError):
pass
class OnionCustomMessageDecodingError(Exception):
pass
class InvalidLocationStringError(Exception):
pass
class OnionDirectoryPeerNotFound(Exception):
pass
class OnionCustomMessage(object):
""" Encapsulates the messages passed over the wire
to and from other onion peers
"""
def __init__(self, text: str, msgtype: int):
self.text = text
self.msgtype = msgtype
def encode(self) -> bytes:
self.encoded = json.dumps({"type": self.msgtype,
"line": self.text}).encode("utf-8")
return self.encoded
@classmethod
def from_string_decode(cls, msg: bytes) -> 'OnionCustomMessage':
""" Build a custom message from a json-ified string.
"""
try:
msg_obj = json.loads(msg)
text = msg_obj["line"]
msgtype = msg_obj["type"]
# we insist on integer but not a valid msgtype,
# crudely 'syntax, not semantics':
# semantics is the job of the OnionMessageChannel object.
assert isinstance(msgtype, int)
assert isinstance(text, str)
except:
# this blanket catch and re-raise:
# we must handle untrusted input bytes without
# crashing under any circumstance.
raise OnionCustomMessageDecodingError
return cls(text, msgtype)
class OnionLineProtocol(basic.LineReceiver):
# there are messages requiring more than LineReceiver's 16KB,
# specifically, large coinjoin transaction `pushtx` messages.
# 40K is finger in the air for: 500bytes per participant, 40
# participants, and a double base64 expansion (x1.33 and x1.33)
# which gives 35.5K, add a little breathing room.
MAX_LENGTH = 40000
def connectionMade(self):
self.factory.register_connection(self)
basic.LineReceiver.connectionMade(self)
def connectionLost(self, reason):
self.factory.register_disconnection(self)
basic.LineReceiver.connectionLost(self, reason)
def lineReceived(self, line: bytes) -> None:
try:
msg = OnionCustomMessage.from_string_decode(line)
except OnionCustomMessageDecodingError:
log.debug("Received invalid message: {}, "
"dropping connection.".format(line))
self.transport.loseConnection()
return
self.factory.receive_message(msg, self)
def message(self, message: OnionCustomMessage) -> None:
self.sendLine(message.encode())
class OnionLineProtocolFactory(protocol.ServerFactory):
""" This factory allows us to start up instances
of the LineReceiver protocol that are instantiated
towards us.
"""
protocol = OnionLineProtocol
def __init__(self, client: 'OnionMessageChannel'):
self.client = client
self.peers = {}
def register_connection(self, p: OnionLineProtocol) -> None:
# make a local control message registering
# the new connection
peer_location = network_addr_to_string(p.transport.getPeer())
self.peers[peer_location] = p
self.client.register_connection(peer_location, direction=0)
def register_disconnection(self, p: OnionLineProtocol) -> None:
# make a local control message registering
# the disconnection
peer_location = network_addr_to_string(p.transport.getPeer())
self.client.register_disconnection(peer_location)
if peer_location not in self.peers:
log.warn("Disconnection event registered for non-existent peer.")
return
del self.peers[peer_location]
def disconnect_inbound_peer(self, inbound_peer_str: str) -> None:
if inbound_peer_str not in self.peers:
log.warn("cannot disconnect peer at {}, not found".format(
inbound_peer_str))
proto = self.peers[inbound_peer_str]
proto.transport.loseConnection()
def receive_message(self, message: OnionCustomMessage,
p: OnionLineProtocol) -> None:
self.client.receive_msg(message, network_addr_to_string(
p.transport.getPeer()))
def send(self, message: OnionCustomMessage, destination: str) -> bool:
if destination not in self.peers:
log.warn("sending message {}, destination {} was not in peers {}".format(
message.encode(), destination, self.peers))
return False
proto = self.peers[destination]
proto.message(message)
return True
class OnionClientFactory(protocol.ClientFactory):
""" We define a distinct protocol factory for outbound connections.
Notably, this factory supports only *one* protocol instance at a time.
"""
protocol = OnionLineProtocol
def __init__(self, message_receive_callback: Callable,
connection_callback: Callable,
disconnection_callback: Callable,
message_not_sendable_callback: Callable,
directory: bool,
mc: 'OnionMessageChannel'):
self.proto_client = None
# callback takes OnionCustomMessage as arg and returns None
self.message_receive_callback = message_receive_callback
# connection callback, no args, returns None
self.connection_callback = connection_callback
# disconnection the same
self.disconnection_callback = disconnection_callback
# a callback that can be fired if we are not able to send messages,
# no args, returns None
self.message_not_sendable_callback = message_not_sendable_callback
# is this connection to a directory?
self.directory = directory
# to keep track of state of overall messagechannel
self.mc = mc
def clientConnectionLost(self, connector, reason):
log.debug('Onion client connection lost: ' + str(reason))
# persistent reconnection is reserved for directories;
# for makers, it isn't logical to keep trying; they may
# well have just shut down the onion permanently, and we can
# reach them via directory anyway.
if self.directory and not self.mc.give_up:
if reactor.running:
log.info('Attempting to reconnect...')
protocol.ClientFactory.clientConnectionLost(self,
connector, reason)
def clientConnectionFailed(self, connector, reason):
log.info('Onion client connection failed: ' + str(reason))
# reasoning here exactly as for clientConnectionLost
if self.directory and not self.mc.give_up:
if reactor.running:
log.info('Attempting to reconnect...')
protocol.ClientFactory.clientConnectionFailed(self,
connector, reason)
def register_connection(self, p: OnionLineProtocol) -> None:
self.proto_client = p
self.connection_callback()
def register_disconnection(self, p: OnionLineProtocol) -> None:
self.proto_client = None
self.disconnection_callback()
def send(self, msg: OnionCustomMessage) -> bool:
# we may be sending at the time the counterparty
# disconnected
if not self.proto_client:
self.message_not_sendable_callback()
return False
self.proto_client.message(msg)
# Unlike the serving protocol, the client protocol
# is never in a condition of not knowing the counterparty
return True
def receive_message(self, message: OnionCustomMessage,
p: OnionLineProtocol) -> None:
self.message_receive_callback(message)
class OnionPeer(object):
""" Class encapsulating a peer we connect to.
"""
def __init__(self, messagechannel: 'OnionMessageChannel',
socks5_host: str, socks5_port: int,
location_tuple: Tuple[str, int],
directory: bool=False, nick: str="",
handshake_callback: Callable=None):
# reference to the managing OnionMessageChannel instance is
# needed so that we know where to send the messages received
# from this peer:
self.messagechannel = messagechannel
self.nick = nick
# client side net config:
self.socks5_host = socks5_host
self.socks5_port = socks5_port
# remote net config:
self.hostname = location_tuple[0]
self.port = location_tuple[1]
# alternate location strings are used for inbound
# connections for this peer (these will be used by
# directories and onion-serving peers, sending
# messages backwards on a connection created towards them).
self.alternate_location = ""
if self.hostname != NOT_SERVING_ONION_HOSTNAME:
# There is no harm in always setting it by default;
# it only gets used if we don't have an outbound.
self.set_alternate_location(location_tuple_to_str(
location_tuple))
if directory and not self.hostname:
raise OnionPeerDirectoryWithoutHostError()
self.directory = directory
self._status = PEER_STATUS_UNCONNECTED
#A function to be called to initiate a handshake;
# it should take a single argument, an OnionPeer object,
#and return None.
self.handshake_callback = handshake_callback
# Keep track of the protocol factory used to connect
# to the remote peer. Note that this won't always be used,
# if we have an inbound connection from this peer:
self.factory = None
# the reconnecting service allows auto-reconnection to
# some peers:
self.reconnecting_service = None
# don't try to connect more than once
# TODO: prefer state machine update
self.connecting = False
def set_alternate_location(self, location_string: str) -> None:
self.alternate_location = location_string
def update_status(self, destn_status: int) -> None:
""" Wrapping state updates to enforce:
(a) that the handshake is triggered by connection
outwards, and (b) to ensure no illegal state transitions.
"""
assert destn_status in range(4)
ignored_updates = []
if self._status == PEER_STATUS_UNCONNECTED:
allowed_updates = [PEER_STATUS_CONNECTED]
elif self._status == PEER_STATUS_CONNECTED:
# updates from connected->connected are harmless
allowed_updates = [PEER_STATUS_CONNECTED,
PEER_STATUS_DISCONNECTED,
PEER_STATUS_HANDSHAKED]
elif self._status == PEER_STATUS_HANDSHAKED:
allowed_updates = [PEER_STATUS_DISCONNECTED]
ignored_updates = [PEER_STATUS_CONNECTED]
elif self._status == PEER_STATUS_DISCONNECTED:
allowed_updates = [PEER_STATUS_CONNECTED]
ignored_updates = [PEER_STATUS_DISCONNECTED]
if destn_status in ignored_updates:
log.debug("Attempt to update status of peer from {} "
"to {} ignored.".format(self._status, destn_status))
return
assert destn_status in allowed_updates, ("couldn't update state "
"from {} to {}".format(self._status, destn_status))
self._status = destn_status
# the handshakes are always initiated by a client:
if destn_status == PEER_STATUS_CONNECTED:
self.connecting = False
log.info("We, {}, are calling the handshake callback as client.".format(
self.messagechannel.self_as_peer.peer_location()))
self.handshake_callback(self)
def status(self) -> int:
""" Simple getter function for the wrapped _status:
"""
return self._status
def set_nick(self, nick: str) -> None:
self.nick = nick
def get_nick_peerlocation_ser(self) -> str:
if not self.nick:
raise OnionPeerError("Cannot serialize "
"identifier string without nick.")
return self.nick + NICK_PEERLOCATOR_SEPARATOR + \
self.peer_location()
@classmethod
def from_location_string(cls, mc: 'OnionMessageChannel',
location: str,
socks5_host: str,
socks5_port: int,
directory: bool=False,
handshake_callback: Callable=None) -> 'OnionPeer':
""" Allows construction of an OnionPeer from the
connection information given by the network interface.
TODO: special handling for inbound is needed.
"""
try:
host, port = location.split(":")
portint = int(port)
except:
raise InvalidLocationStringError(location)
return cls(mc, socks5_host, socks5_port,
(host, portint), directory=directory,
handshake_callback=handshake_callback)
def set_location(self, location_string: str) -> bool:
""" Allows setting location from an unchecked
input string argument.
If the location is specified as the 'no serving' case,
we put the currently existing inbound connection as the alternate
location, and the NOT_SERVING const as the 'location', returning True.
If the string does not have the required format, will return False,
otherwise self.hostname, self.port are
updated for future `peer_location` calls, and True is returned.
"""
if location_string == NOT_SERVING_ONION_HOSTNAME:
self.set_alternate_location(location_tuple_to_str(
(self.hostname, self.port)))
self.hostname = NOT_SERVING_ONION_HOSTNAME
self.port = -1
return True
try:
host, port = location_string.split(":")
portint = int(port)
assert portint > 0
except Exception as e:
log.debug("Failed to update host and port of this peer, "
"error: {}".format(repr(e)))
return False
self.hostname = host
self.port = portint
return True
def peer_location(self) -> str:
if self.hostname == NOT_SERVING_ONION_HOSTNAME:
# special case for non-reachable peers, which can include
# self_as_peer: we just return this string constant
return NOT_SERVING_ONION_HOSTNAME
# in every other case we need a sensible port/host combo:
assert (self.port > 0 and self.hostname)
return location_tuple_to_str((self.hostname, self.port))
def send(self, message: OnionCustomMessage) -> bool:
""" If the message can be sent on either an inbound or
outbound connection, True is returned, else False.
"""
if not self.factory:
# we try to send via the overall message channel serving
# protocol, i.e. we assume the connection was made inbound:
return self.messagechannel.proto_factory.send(message,
self.alternate_location)
return self.factory.send(message)
def receive_message(self, message: OnionCustomMessage) -> None:
self.messagechannel.receive_msg(message, self.peer_location())
def notify_message_unsendable(self):
""" Triggered by a failure to send a message on the network,
by the encapsulated ClientFactory. Just used to notify calling
code; no action is triggered.
"""
name = "directory" if self.directory else "peer"
log.warn("Failure to send message to {}: {}.".format(
name, self.peer_location()))
def connect(self) -> None:
""" This method is called to connect, over Tor, to the remote
peer at the given onion host/port.
"""
if self.connecting:
return
self.connecting = True
if self._status in [PEER_STATUS_HANDSHAKED, PEER_STATUS_CONNECTED]:
return
if not (self.hostname and self.port > 0):
raise OnionPeerConnectionError(
"Cannot connect without host, port info")
self.factory = OnionClientFactory(self.receive_message,
self.register_connection, self.register_disconnection,
self.notify_message_unsendable, self.directory, self.messagechannel)
if testing_mode:
log.debug("{} is making a tcp connection to {}, {}, {},".format(
self.messagechannel.self_as_peer.peer_location(), self.hostname,
self.port, self.factory))
self.tcp_connector = reactor.connectTCP(self.hostname, self.port,
self.factory)
else:
# non-default timeout; needs to be much lower than our
# 'wait at least a minute for the IRC connections to come up',
# which is used for *all* message channels, together.
torEndpoint = TCP4ClientEndpoint(reactor, self.socks5_host,
self.socks5_port,
timeout=CONNECT_TO_ONION_TIMEOUT)
onionEndpoint = TorSocksEndpoint(torEndpoint, self.hostname,
self.port)
self.reconnecting_service = ClientService(onionEndpoint, self.factory)
# if we want to actually do something about an unreachable host,
# we have to force t.a.i.ClientService to give up after the timeout
d = self.reconnecting_service.whenConnected(failAfterFailures=1)
d.addCallbacks(self.respond_to_connection_success,
self.respond_to_connection_failure)
self.reconnecting_service.startService()
def respond_to_connection_success(self, proto) -> None:
self.connecting = False
def respond_to_connection_failure(self, failure) -> None:
self.connecting = False
# the error will be one of these if we just fail
# to connect to the other side.
failure.trap(HostUnreachableError, SocksError, GeneralServerFailureError)
comment = "" if self.directory else "; giving up."
log.info(f"Failed to connect to peer {self.peer_location()}{comment}")
self.reconnecting_service.stopService()
def register_connection(self) -> None:
self.messagechannel.register_connection(self.peer_location(),
direction=1)
def register_disconnection(self) -> None:
# for non-directory peers, just stop
self.reconnecting_service.stopService()
self.messagechannel.register_disconnection(self.peer_location())
def try_to_connect(self) -> None:
""" This method wraps OnionPeer.connect and accepts
any error if that fails.
"""
try:
self.connect()
except OnionPeerConnectionError as e:
# Note that this will happen naturally for non-serving peers.
# TODO remove message or change it.
log.debug("Tried to connect but failed: {}".format(repr(e)))
except Exception as e:
log.warn("Got unexpected exception in connect attempt: {}".format(
repr(e)))
def disconnect(self) -> None:
if self._status in [PEER_STATUS_UNCONNECTED, PEER_STATUS_DISCONNECTED]:
return
if not (self.hostname and self.port > 0):
raise OnionPeerConnectionError(
"Cannot disconnect without host, port info")
if self.factory:
d = self.reconnecting_service.stopService()
d.addCallback(self.complete_disconnection)
else:
self.messagechannel.proto_factory.disconnect_inbound_peer(
self.alternate_location)
def complete_disconnection(self, r) -> None:
log.debug("Disconnected from peer: {}".format(self.peer_location()))
self.update_status(PEER_STATUS_DISCONNECTED)
self.factory = None
class OnionPeerPassive(OnionPeer):
""" a type of remote peer that we are
not interested in connecting outwards to.
"""
def try_to_connect(self) -> None:
pass
class OnionDirectoryPeer(OnionPeer):
delay = 4.0
def try_to_connect(self) -> None:
# Delay deliberately expands out to very
# long times as yg-s tend to be very long
# running bots:
# We will only expand delay 20 times max
# (4 * 1.5^19 = 8867.3)
if self.delay < 8868:
self.delay *= 1.5
# randomize by a few seconds to minimize bursty-ness locally
jitter = random.randint(-1, 5)
log.info(f"Going to reattempt connection to {self.peer_location()} in "
f"{self.delay + jitter} seconds.")
reactor.callLater(self.delay + jitter, self.connect)
def register_connection(self) -> None:
self.messagechannel.update_directory_map(self, connected=True)
super().register_connection()
def register_disconnection(self) -> None:
self.messagechannel.update_directory_map(self, connected=False)
super().register_disconnection()
# for directory peers, we persist in trying to establish
# a connection, but with backoff:
self.try_to_connect()
def respond_to_connection_failure(self, failure) -> None:
super().respond_to_connection_failure(failure)
# same logic as for register_disconnection
self.try_to_connect()
class OnionMessageChannel(MessageChannel):
""" Sends messages to other nodes of the same type over Tor
via SOCKS5.
*Optionally*: Receives messages via a Torv3 hidden/onion service.
If no onion service, it means we only have connections outbound
to other onion services (directory nodes first, others if and
when they send us a privmsg.).
Uses one or more configured "directory nodes" (which could be us)
to access a list of current active nodes, and updates
dynamically from messages seen.
"""
def __init__(self,
configdata,
daemon=None):
MessageChannel.__init__(self, daemon=daemon)
# hostid is a feature to avoid replay attacks across message channels;
# TODO investigate, but for now, treat onion-based as one "server".
self.hostid = "onion-network"
self.btc_network = configdata["btcnet"]
# receives notification that we are shutting down
self.give_up = False
# for backwards compat: make sure MessageChannel log can refer to
# this in dynamic switch message:
self.serverport = self.hostid
self.tor_control_host = configdata["tor_control_host"]
self.tor_control_port = configdata["tor_control_port"]
self.onion_serving_host=configdata["onion_serving_host"]
self.onion_serving = configdata["serving"]
if self.onion_serving:
self.onion_serving_port = configdata["onion_serving_port"]
self.hidden_service_dir = configdata["hidden_service_dir"]
# client side config:
self.socks5_host = configdata["socks5_host"]
self.socks5_port = configdata["socks5_port"]
# passive configuration is for bots who never need/want to connect
# to peers (apart from directories)
self.passive = False
if "passive" in configdata:
self.passive = configdata["passive"]
# we use the setting in the config sent over from
# the client, to decide whether to set up our connections
# over localhost (if testing), without Tor:
set_testing_mode(configdata)
# keep track of peers. the list will be instances
# of OnionPeer:
self.peers = set()
for dn in [x.strip() for x in configdata["directory_nodes"].split(",")]:
# note we don't use a nick for directories:
try:
self.peers.add(OnionDirectoryPeer.from_location_string(
self, dn, self.socks5_host, self.socks5_port,
directory=True, handshake_callback=self.handshake_as_client))
except InvalidLocationStringError as e:
log.error("Failed to load directory nodes: {}".format(repr(e)))
stop_reactor()
return
# we can direct messages via the protocol factory, which
# will index protocol connections by peer location:
self.proto_factory = OnionLineProtocolFactory(self)
if self.onion_serving:
if testing_mode:
# we serve over TCP:
self.testing_serverconn = reactor.listenTCP(self.onion_serving_port,
self.proto_factory, interface="localhost")
self.onion_hostname = "127.0.0.1"
else:
self.hs = JMHiddenService(self.proto_factory,
self.info_callback,
self.setup_error_callback,
self.onion_hostname_callback,
self.tor_control_host,
self.tor_control_port,
self.onion_serving_host,
self.onion_serving_port,
virtual_port=ONION_VIRTUAL_PORT,
shutdown_callback=self.shutdown_callback,
hidden_service_dir=self.hidden_service_dir)
# this call will start bringing up the HS; when it's finished,
# it will fire the `onion_hostname_callback`, or if it fails,
# it'll fire the `setup_error_callback`.
self.hs.start_tor()
# For tor-managed services, the hostname is set synchronously by start_tor()
# For ephemeral services, we need to wait for the callback
if not self.hidden_service_dir.startswith("tor-managed:"):
# This will serve as our unique identifier, indicating
# that we are ready to communicate (in both directions) over Tor.
self.onion_hostname = None
else:
# dummy 'hostname' to indicate we can start running immediately:
self.onion_hostname = NOT_SERVING_ONION_HOSTNAME
# intended to represent the special case of 'we are the
# only directory node known', however for now dns don't interact
# so this has no role. TODO probably remove it.
self.genesis_node = False
# waiting loop for all directories to have
# connected (note we could use a deferred but
# the rpc connection calls are not using twisted)
self.wait_for_directories_loop = None
# this dict plays the same role as `active_channels` in `MessageChannelCollection`.
# it has structure {nick1: {}, nick2: {}, ...} where the inner dicts are:
# {OnionDirectoryPeer1: bool, OnionDirectoryPeer2: bool, ...}.
# Entries get updated with changing connection status of directories,
# allowing us to decide where to send each message we want to send when we have no
# direct connection.
self.active_directories = {}
def info_callback(self, msg: str) -> None:
log.info(msg)
def setup_error_callback(self, msg: str) -> None:
log.error(msg)
def shutdown_callback(self, msg: str) -> None:
log.info("in shutdown callback: {}".format(msg))
def onion_hostname_callback(self, hostname: str) -> None:
""" This entrypoint marks the start of the OnionMessageChannel
running, since we need this unique identifier as our name
before we can start working (we need to compare it with the
configured directory nodes).
"""
log.info("setting onion hostname to : {}".format(hostname))
self.onion_hostname = hostname
# ABC implementation section
def run(self) -> None:
self.hs_up_loop = task.LoopingCall(self.check_onion_hostname)
self.hs_up_loop.start(0.5)
def shutdown(self) -> None:
self.give_up = True
for p in self.peers:
if p.reconnecting_service:
p.reconnecting_service.stopService()
def get_pubmsg(self, msg:str, source_nick:str ="") -> str:
""" Converts a message into the known format for
pubmsgs; if we are not sending this (because we
are a directory, forwarding it), `source_nick` must be set.
Note that pubmsg does NOT prefix the *message* with COMMAND_PREFIX.
"""
nick = source_nick if source_nick else self.nick
return nick + COMMAND_PREFIX + "PUBLIC" + msg
def get_privmsg(self, nick: str, cmd: str, message: str,
source_nick=None) -> str:
""" See `get_pubmsg` for comment on `source_nick`.
"""
from_nick = source_nick if source_nick else self.nick
return from_nick + COMMAND_PREFIX + nick + COMMAND_PREFIX + \
cmd + " " + message
def _pubmsg(self, msg:str) -> None:
""" Best effort broadcast of message `msg`:
send the message to every known directory node,
with the PUBLIC message type and nick.
"""
dps = self.get_directory_peers()
msg = OnionCustomMessage(self.get_pubmsg(msg),
JM_MESSAGE_TYPES["pubmsg"])
for dp in dps:
# currently a directory node can send its own
# pubmsgs (act as maker or taker); this will
# probably be removed but is useful in testing:
if dp == self.self_as_peer:
self.receive_msg(msg, "00")
else:
self._send(dp, msg)
def should_try_to_connect(self, peer: OnionPeer) -> bool:
if not peer:
return False
if peer.peer_location() == NOT_SERVING_ONION_HOSTNAME:
return False
if peer.directory:
return False
if peer == self.self_as_peer:
return False
if peer.status() in [PEER_STATUS_CONNECTED, PEER_STATUS_HANDSHAKED]:
return False
return True
def _privmsg(self, nick: str, cmd: str, msg:str) -> None:
# in certain test scenarios the directory may try to transfer
# commitments to itself:
if nick == self.nick:
log.debug("Not sending message to ourselves: {}, {}, {}".format(
nick, cmd, msg))
return
encoded_privmsg = OnionCustomMessage(self.get_privmsg(nick, cmd, msg),
JM_MESSAGE_TYPES["privmsg"])
peer_exists = self.get_peer_by_nick(nick, conn_only=False)
peer_sendable = self.get_peer_by_nick(nick)
# opportunistically connect to peers that have talked to us
# (evidenced by the peer existing, which must be because we got
# a `peerlist` message for it), and that we want to talk to
# (evidenced by the call to this function)
if self.should_try_to_connect(peer_exists):
reactor.callLater(0.0, peer_exists.try_to_connect)
if not peer_sendable:
# If we are trying to message a peer via their nick, we
# may not yet have a connection; then we just
# forward via directory nodes.
log.debug("Privmsg peer: {} but don't have peerid; "
"sending via directory.".format(nick))
try:
peer_sendable = self.get_directory_for_nick(nick)
except OnionDirectoryPeerNotFound:
log.warn("Failed to send privmsg because no "
"directory peer is connected.")
return
self._send(peer_sendable, encoded_privmsg)
def _announce_orders(self, offerlist: list) -> None:
for offer in offerlist:
self._pubmsg(offer)
# End ABC implementation section
def check_onion_hostname(self) -> None:
if not self.onion_hostname:
return
self.hs_up_loop.stop()
# now our hidden service is up, we must check our peer status
# then set up directories.
self.get_our_peer_info()
# at this point the only peers added are directory
# nodes from config; we try to connect to all.
# We will get other peers to add to our list once they
# start sending us messages.
reactor.callLater(0.0, self.connect_to_directories)
def get_my_location_tuple(self) -> Tuple[str, int]:
if self.onion_hostname == NOT_SERVING_ONION_HOSTNAME:
return (self.onion_hostname, -1)
elif testing_mode:
return (self.onion_hostname, self.onion_serving_port)
else:
return (self.onion_hostname, ONION_VIRTUAL_PORT)
def get_our_peer_info(self) -> None:
""" Create a special OnionPeer object,
outside of our peerlist, to refer to ourselves.
"""
dps = self.get_directory_peers()
self_dir = False
# only for publicly exposed onion does the 'virtual port' exist;
# for local tests we always connect to an actual machine port:
my_location_tuple = self.get_my_location_tuple()
my_location_str = location_tuple_to_str(my_location_tuple)
if [my_location_str] == [d.peer_location() for d in dps]:
log.info("This is the genesis node: {}".format(self.onion_hostname))
self.genesis_node = True
self_dir = True
elif my_location_str in dps:
# Here we are just one of many directory nodes,
# which should be fine, we should just be careful
# to not query ourselves.
self_dir = True
self.self_as_peer = OnionPeer(self, self.socks5_host, self.socks5_port,
my_location_tuple,
self_dir, nick=self.nick,
handshake_callback=None)
def connect_to_directories(self) -> None:
if self.genesis_node:
# we are a directory and we have no directory peers;
# just start.
self._start_listener()
return
# the remaining code is only executed by non-directories:
for p in self.peers:
log.info("Trying to connect to node: {}".format(p.peer_location()))
try:
p.connect()
except OnionPeerConnectionError:
pass
# do not trigger on_welcome event until all directories
# configured are ready:
self.on_welcome_sent = False
self.directory_wait_counter = 0
self.wait_for_directories_loop = task.LoopingCall(
self.wait_for_directories)
self.wait_for_directories_loop.start(2.0)
def _start_listener(self) -> None:
serverstring = f"tcp:{self.onion_serving_port}:interface={self.onion_serving_host}"
onion_endpoint = serverFromString(reactor, serverstring)
d = onion_endpoint.listen(self.proto_factory)
d.addCallback(self.on_welcome)
d.addErrback(lambda f: self.setup_error_callback(f"Listen failed: {f}"))
def handshake_as_client(self, peer: OnionPeer) -> None:
assert peer.status() == PEER_STATUS_CONNECTED
if self.self_as_peer.directory:
log.debug("Not sending client handshake to {} because we "
"are directory.".format(peer.peer_location()))
return
our_hs = copy.deepcopy(client_handshake_json)
our_hs["location-string"] = self.self_as_peer.peer_location()
our_hs["nick"] = self.nick
our_hs["network"] = self.btc_network
our_hs_json = json.dumps(our_hs)
log.info("Sending this handshake: {} to peer {}".format(
our_hs_json, peer.peer_location()))
self._send(peer, OnionCustomMessage(our_hs_json,
CONTROL_MESSAGE_TYPES["handshake"]))
def handshake_as_directory(self, peer: OnionPeer, our_hs: dict) -> None:
assert peer.status() == PEER_STATUS_CONNECTED
our_hs["network"] = self.btc_network
our_hs_json = json.dumps(our_hs)
log.info("Sending this handshake as directory: {}".format(
our_hs_json))
self._send(peer, OnionCustomMessage(our_hs_json,
CONTROL_MESSAGE_TYPES["dn-handshake"]))
def get_directory_peers(self) -> list:
return [p for p in self.peers if p.directory is True]
def get_peer_by_nick(self, nick:str, conn_only:bool=True) -> Union[OnionPeer, None]:
""" Return an OnionPeer object matching the given Joinmarket
nick; if `conn_only` is True, we restrict to only those peers
in state PEER_STATUS_HANDSHAKED, else we allow any peer.
If no such peer can be found, return None.
"""
plist = self.get_all_connected_peers() if conn_only else self.peers
for p in plist:
if p.nick == nick:
return p
def _send(self, peer: OnionPeer, message: OnionCustomMessage) -> bool:
try:
return peer.send(message)
except Exception as e:
# This can happen when a peer disconnects, depending
# on the timing:
log.warn("Failed to send message to: {}, error: {}".format(
peer.peer_location(), repr(e)))
return False
def receive_msg(self, message: OnionCustomMessage, peer_location: str) -> None:
""" Messages from peers and also connection related control
messages. These messages either come via OnionPeer or via
the main OnionLineProtocolFactory instance that handles all
inbound connections.
"""
if self.self_as_peer.directory:
# TODO remove, useful while testing
log.debug("received message as directory: {}".format(message.encode()))
peer = self.get_peer_by_id(peer_location)
if not peer:
log.warn("Received message but could not find peer: {}".format(peer_location))
return
msgtype = message.msgtype
msgval = message.text
if msgtype in LOCAL_CONTROL_MESSAGE_TYPES.values():
self.process_control_message(peer_location, msgtype, msgval)
# local control messages are processed first.
# TODO this is a historical artifact, we can simplify.
return
if self.process_control_message(peer_location, msgtype, msgval):
# will return True if it is, elsewise, a control message.
return
# ignore non-JM messages:
if msgtype not in JM_MESSAGE_TYPES.values():
log.debug("Invalid message type, ignoring: {}".format(msgtype))
return
# real JM message; should be: from_nick, to_nick, cmd, message
try:
nicks_msgs = msgval.split(COMMAND_PREFIX)
from_nick, to_nick = nicks_msgs[:2]
msg = COMMAND_PREFIX + COMMAND_PREFIX.join(nicks_msgs[2:])
if to_nick == "PUBLIC":
self.on_pubmsg(from_nick, msg)
if self.self_as_peer.directory: