WIP: Add support for tor using PySocks and optionally stem #2

Draft
lee.miller wants to merge 22 commits from lee.miller/MiNode:tor into v0.3
16 changed files with 510 additions and 20 deletions

View File

@ -9,7 +9,8 @@ RUN apt-add-repository ppa:purplei2p/i2pd && apt-get update -qq
RUN apt-get install -yq --no-install-suggests --no-install-recommends \ RUN apt-get install -yq --no-install-suggests --no-install-recommends \
python3-dev python3-pip python-is-python3 python3.11-dev python3.11-venv python3-dev python3-pip python-is-python3 python3.11-dev python3.11-venv
RUN apt-get install -yq --no-install-suggests --no-install-recommends sudo i2pd RUN apt-get install -yq --no-install-suggests --no-install-recommends \
sudo i2pd tor
RUN echo 'builder ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers RUN echo 'builder ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

View File

@ -1,3 +1,4 @@
#!/bin/sh #!/bin/sh
sudo service i2pd start sudo service i2pd start
sudo service tor start

View File

@ -30,7 +30,7 @@ usage: main.py [-h] [-p PORT] [--host HOST] [--debug] [--data-dir DATA_DIR]
[--connection-limit CONNECTION_LIMIT] [--i2p] [--connection-limit CONNECTION_LIMIT] [--i2p]
[--i2p-tunnel-length I2P_TUNNEL_LENGTH] [--i2p-tunnel-length I2P_TUNNEL_LENGTH]
[--i2p-sam-host I2P_SAM_HOST] [--i2p-sam-port I2P_SAM_PORT] [--i2p-sam-host I2P_SAM_HOST] [--i2p-sam-port I2P_SAM_PORT]
[--i2p-transient] [--i2p-transient] [--socks-proxy SOCKS_PROXY] [--tor]
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -53,6 +53,10 @@ optional arguments:
--i2p-sam-port I2P_SAM_PORT --i2p-sam-port I2P_SAM_PORT
Port of I2P SAMv3 bridge Port of I2P SAMv3 bridge
--i2p-transient Generate new I2P destination on start --i2p-transient Generate new I2P destination on start
--socks-proxy SOCKS_PROXY
SOCKS proxy address in the form <HOST>:<PORT>
--tor The SOCKS proxy is tor, use 127.0.0.1:9050 if not
specified, start tor and setup a hidden service
``` ```

View File

@ -5,6 +5,7 @@ import errno
import logging import logging
import math import math
import random import random
import re
import select import select
import socket import socket
import ssl import ssl
@ -78,7 +79,11 @@ class ConnectionBase(threading.Thread):
self.s.settimeout(0) self.s.settimeout(0)
if not self.server: if not self.server:
if self.network == 'ip': if self.network == 'ip':
self.send_queue.put(message.Version(self.host, self.port)) version_kwargs = (
{'services': 1} if self.host.endswith('.onion') else {})
self.send_queue.put(message.Version(
('127.0.0.1' if shared.socks_proxy else self.host),
self.port, **version_kwargs))
else: else:
self.send_queue.put(message.Version('127.0.0.1', 7656)) self.send_queue.put(message.Version('127.0.0.1', 7656))
while True: while True:
@ -257,12 +262,17 @@ class ConnectionBase(threading.Thread):
'Established Bitmessage protocol connection to %s:%s', 'Established Bitmessage protocol connection to %s:%s',
self.host_print, self.port) self.host_print, self.port)
self.on_connection_fully_established_scheduled = False self.on_connection_fully_established_scheduled = False
if self.remote_version.services & 2 and self.network == 'ip': if ( # NODE_SSL
self._do_tls_handshake() # NODE_SSL self.remote_version.services & 2 and self.network == 'ip'
and not self.host.endswith('.onion')
and not (self.server and shared.tor)
):
self._do_tls_handshake()
addr = { addr = {
structure.NetAddr(c.remote_version.services, c.host, c.port) structure.NetAddr(c.remote_version.services, c.host, c.port)
for c in shared.connections if c.network != 'i2p' for c in shared.connections if c.network != 'i2p'
and not c.host.endswith('.onion')
and c.server is False and c.status == 'fully_established'} and c.server is False and c.status == 'fully_established'}
# pylint: disable=unsubscriptable-object # pylint: disable=unsubscriptable-object
# https://github.com/pylint-dev/pylint/issues/3637 # https://github.com/pylint-dev/pylint/issues/3637
@ -393,17 +403,25 @@ class ConnectionBase(threading.Thread):
if not self.server: if not self.server:
self.send_queue.put('fully_established') self.send_queue.put('fully_established')
if self.network == 'ip': if self.network == 'ip':
shared.address_advertise_queue.put(structure.NetAddr( if self.host.endswith('.onion'):
version.services, self.host, self.port)) shared.onion_pool.add((self.host, self.port))
shared.node_pool.add((self.host, self.port)) else:
shared.address_advertise_queue.put(structure.NetAddr(
version.services, self.host, self.port))
shared.node_pool.add((self.host, self.port))
elif self.network == 'i2p': elif self.network == 'i2p':
shared.i2p_node_pool.add((self.host, 'i2p')) shared.i2p_node_pool.add((self.host, 'i2p'))
if self.network == 'ip': if (
self.network == 'ip' and shared.listen_for_connections
and version.host != '127.0.0.1'
):
shared.address_advertise_queue.put(structure.NetAddr( shared.address_advertise_queue.put(structure.NetAddr(
shared.services, version.host, shared.listening_port)) shared.services, version.host, shared.listening_port))
if self.server: if self.server:
if self.network == 'ip': if self.network == 'ip':
self.send_queue.put(message.Version(self.host, self.port)) version_kwargs = {'services': 1} if shared.tor else {}
self.send_queue.put(message.Version(
self.host, self.port, **version_kwargs))
else: else:
self.send_queue.put(message.Version('127.0.0.1', 7656)) self.send_queue.put(message.Version('127.0.0.1', 7656))
@ -500,6 +518,13 @@ class Connection(ConnectionBase):
' adding to i2p_unchecked_node_pool') ' adding to i2p_unchecked_node_pool')
logging.debug(dest) logging.debug(dest)
shared.i2p_unchecked_node_pool.add((dest, 'i2p')) shared.i2p_unchecked_node_pool.add((dest, 'i2p'))
elif (
obj.object_type == shared.onion_obj_type
and obj.version == shared.onion_obj_version
):
peer = structure.OnionPeer.from_object(obj)
logging.debug('Received onion peer object: %s', peer)
shared.onion_unchecked_pool.add((peer.host, peer.port))
shared.vector_advertise_queue.put(obj.vector) shared.vector_advertise_queue.put(obj.vector)
def _process_msg_getdata(self, m): def _process_msg_getdata(self, m):
@ -517,4 +542,46 @@ class Bootstrapper(ConnectionBase):
self.send_queue.put(None) self.send_queue.put(None)
class SocksConnection(Connection):
"""The socks proxied connection"""
def _connect(self):
peer_str = '{0.host_print}:{0.port}'.format(self)
logging.debug('Connecting to %s', peer_str)
import socks
proxy_type = socks.PROXY_TYPES[shared.socks_proxy.scheme.upper()]
try:
self.s = socks.create_connection(
(self.host, self.port), 30, None, proxy_type,
shared.socks_proxy.hostname, shared.socks_proxy.port, True,
shared.socks_proxy.username, shared.socks_proxy.password, None)
self.status = 'connected'
logging.debug('Established SOCKS connection to %s', peer_str)
except socket.timeout:
pass
except socks.GeneralProxyError as e:
e = e.socket_err
if isinstance(e, socket.timeout) or (
# general failure, unreachable, refused
not e.errno and re.match(r'^0x0[1,4,5].*', e.msg)
):
logcall = logging.debug
else:
logcall = logging.info
logcall('Connection to %s failed. Reason: %s', peer_str, e)
except OSError as e:
# unreachable, refused, no route
(logging.info if e.errno not in (0, 101, 111, 113)
else logging.debug)(
'Connection to %s failed. Reason: %s', peer_str, e)
except Exception:
logging.info(
'Connection to %s failed.', peer_str, exc_info=True)
if self.status != 'connected':
self.status = 'failed'
shared.connection = Connection shared.connection = Connection

View File

@ -5,8 +5,15 @@ import base64
import logging import logging
import multiprocessing import multiprocessing
import os import os
import re
import signal import signal
import socket import socket
from urllib import parse
try:
import socks
except ImportError:
socks = None
from . import i2p, shared from . import i2p, shared
from .advertiser import Advertiser from .advertiser import Advertiser
@ -52,6 +59,16 @@ def parse_arguments(): # pylint: disable=too-many-branches,too-many-statements
'--i2p-transient', action='store_true', '--i2p-transient', action='store_true',
help='Generate new I2P destination on start') help='Generate new I2P destination on start')
if socks is not None:
parser.add_argument(
'--socks-proxy',
help='SOCKS proxy address in the form <HOST>:<PORT>')
parser.add_argument(
'--tor', action='store_true',
help='The SOCKS proxy is tor, use 127.0.0.1:9050 if not specified,'
' start tor and setup a hidden service'
)
args = parser.parse_args() args = parser.parse_args()
if args.port: if args.port:
shared.listening_port = args.port shared.listening_port = args.port
@ -71,7 +88,8 @@ def parse_arguments(): # pylint: disable=too-many-branches,too-many-statements
if args.no_ip: if args.no_ip:
shared.ip_enabled = False shared.ip_enabled = False
if args.trusted_peer: if args.trusted_peer:
if len(args.trusted_peer) > 50: if len(args.trusted_peer
) > 50 and not args.trusted_peer.endswith('onion'):
# I2P # I2P
shared.trusted_peer = (args.trusted_peer.encode(), 'i2p') shared.trusted_peer = (args.trusted_peer.encode(), 'i2p')
else: else:
@ -99,6 +117,17 @@ def parse_arguments(): # pylint: disable=too-many-branches,too-many-statements
if args.i2p_transient: if args.i2p_transient:
shared.i2p_transient = True shared.i2p_transient = True
if socks is None:
return
if args.tor:
shared.tor = True
if not args.socks_proxy:
args.socks_proxy = '127.0.0.1:9050'
if args.socks_proxy:
if not re.match(r'^.*://', args.socks_proxy):
args.socks_proxy = '//' + args.socks_proxy
shared.socks_proxy = parse.urlparse(args.socks_proxy, scheme='socks5')
def bootstrap_from_dns(): def bootstrap_from_dns():
"""Addes addresses of bootstrap servers to core nodes""" """Addes addresses of bootstrap servers to core nodes"""
@ -242,7 +271,31 @@ def main():
'Error while creating data directory in: %s', 'Error while creating data directory in: %s',
shared.data_directory, exc_info=True) shared.data_directory, exc_info=True)
if shared.ip_enabled and not shared.trusted_peer: if shared.socks_proxy and shared.send_outgoing_connections:
try:
socks.PROXY_TYPES[shared.socks_proxy.scheme.upper()]
except KeyError:
logging.error('Unsupported proxy schema!')
return
if shared.tor:
try:
from . import tor
except ImportError:
logging.info('Failed to import tor module.', exc_info=True)
tor = None
else:
if not tor.start_tor_service():
logging.warning('The tor service has hot started.')
tor = None
if not tor:
try:
socket.socket().bind(('127.0.0.1', 9050))
return
except (OSError, socket.error):
pass
if shared.ip_enabled and not shared.trusted_peer and not shared.tor:
bootstrap_from_dns() bootstrap_from_dns()
if shared.i2p_enabled: if shared.i2p_enabled:

View File

@ -11,7 +11,7 @@ import threading
import time import time
from . import proofofwork, shared, structure from . import proofofwork, shared, structure
from .connection import Bootstrapper, Connection from .connection import Bootstrapper, Connection, SocksConnection
from .i2p import I2PDialer from .i2p import I2PDialer
@ -28,6 +28,10 @@ class Manager(threading.Thread):
# Publish destination 5-15 minutes after start # Publish destination 5-15 minutes after start
self.last_published_i2p_destination = \ self.last_published_i2p_destination = \
time.time() - 50 * 60 + random.uniform(-1, 1) * 300 # nosec B311 time.time() - 50 * 60 + random.uniform(-1, 1) * 300 # nosec B311
# Publish onion 4-8 min later
self.last_published_onion_peer = \
self.last_published_i2p_destination - 3 * 3600 + \
random.uniform(1, 2) * 240 # nosec B311
def fill_bootstrap_pool(self): def fill_bootstrap_pool(self):
"""Populate the bootstrap pool by core nodes and checked ones""" """Populate the bootstrap pool by core nodes and checked ones"""
@ -59,6 +63,9 @@ class Manager(threading.Thread):
if now - self.last_published_i2p_destination > 3600: if now - self.last_published_i2p_destination > 3600:
self.publish_i2p_destination() self.publish_i2p_destination()
self.last_published_i2p_destination = now self.last_published_i2p_destination = now
if now - self.last_published_onion_peer > 3600 * 4:
self.publish_onion_peer()
self.last_published_onion_peer = now
@staticmethod @staticmethod
def clean_objects(): def clean_objects():
@ -126,17 +133,31 @@ class Manager(threading.Thread):
): ):
if shared.ip_enabled: if shared.ip_enabled:
if len(shared.unchecked_node_pool) > 16: sample_length = 16
if shared.tor:
if len(shared.onion_unchecked_pool) > 4:
to_connect.update(random.sample(
tuple(shared.onion_unchecked_pool), 4))
else:
to_connect.update(shared.onion_unchecked_pool)
shared.onion_unchecked_pool.difference_update(to_connect)
if len(shared.onion_pool) > 2:
to_connect.update(random.sample(
tuple(shared.onion_pool), 2))
else:
to_connect.update(shared.onion_pool)
sample_length = 8
if len(shared.unchecked_node_pool) > sample_length:
to_connect.update(random.sample( to_connect.update(random.sample(
tuple(shared.unchecked_node_pool), 16)) tuple(shared.unchecked_node_pool), sample_length))
else: else:
to_connect.update(shared.unchecked_node_pool) to_connect.update(shared.unchecked_node_pool)
if outgoing_connections < shared.outgoing_connections / 2: if outgoing_connections < shared.outgoing_connections / 2:
bootstrap() bootstrap()
shared.unchecked_node_pool.difference_update(to_connect) shared.unchecked_node_pool.difference_update(to_connect)
if len(shared.node_pool) > 8: if len(shared.node_pool) > sample_length / 2:
to_connect.update(random.sample( to_connect.update(random.sample(
tuple(shared.node_pool), 8)) tuple(shared.node_pool), int(sample_length / 2)))
else: else:
to_connect.update(shared.node_pool) to_connect.update(shared.node_pool)
@ -174,7 +195,9 @@ class Manager(threading.Thread):
else: else:
continue continue
else: else:
connect((host, port)) connect(
(host, port),
Connection if not shared.socks_proxy else SocksConnection)
hosts.add(group) hosts.add(group)
shared.hosts = hosts shared.hosts = hosts
@ -214,6 +237,17 @@ class Manager(threading.Thread):
logging.warning( logging.warning(
'Error while loading nodes from disk.', exc_info=True) 'Error while loading nodes from disk.', exc_info=True)
try:
with open(
os.path.join(shared.data_directory, 'onion_nodes.pickle'), 'br'
) as src:
shared.onion_pool = pickle.load(src)
except FileNotFoundError:
pass
except Exception:
logging.warning(
'Error while loading nodes from disk.', exc_info=True)
with open( with open(
os.path.join(shared.source_directory, 'core_nodes.csv'), os.path.join(shared.source_directory, 'core_nodes.csv'),
'r', newline='', encoding='ascii' 'r', newline='', encoding='ascii'
@ -259,6 +293,13 @@ class Manager(threading.Thread):
shared.i2p_unchecked_node_pool = set(random.sample( shared.i2p_unchecked_node_pool = set(random.sample(
tuple(shared.i2p_unchecked_node_pool), 100)) tuple(shared.i2p_unchecked_node_pool), 100))
if len(shared.onion_pool) > 1000:
shared.onion_pool = set(
random.sample(shared.onion_pool, 1000))
if len(shared.onion_unchecked_pool) > 100:
shared.onion_unchecked_pool = set(
random.sample(shared.onion_unchecked_pool, 100))
try: try:
with open( with open(
os.path.join(shared.data_directory, 'nodes.pickle'), 'bw' os.path.join(shared.data_directory, 'nodes.pickle'), 'bw'
@ -268,7 +309,11 @@ class Manager(threading.Thread):
os.path.join(shared.data_directory, 'i2p_nodes.pickle'), 'bw' os.path.join(shared.data_directory, 'i2p_nodes.pickle'), 'bw'
) as dst: ) as dst:
pickle.dump(shared.i2p_node_pool, dst, protocol=3) pickle.dump(shared.i2p_node_pool, dst, protocol=3)
logging.debug('Saved nodes') with open(
os.path.join(shared.data_directory, 'onion_nodes.pickle'), 'bw'
) as dst:
pickle.dump(shared.onion_pool, dst, protocol=3)
logging.debug('Saved nodes')
except Exception: except Exception:
logging.warning('Error while saving nodes', exc_info=True) logging.warning('Error while saving nodes', exc_info=True)
@ -283,3 +328,11 @@ class Manager(threading.Thread):
shared.i2p_dest_obj_type, shared.i2p_dest_obj_version, shared.i2p_dest_obj_type, shared.i2p_dest_obj_version,
shared.stream, dest_pub_raw) shared.stream, dest_pub_raw)
proofofwork.do_pow_and_publish(obj) proofofwork.do_pow_and_publish(obj)
@staticmethod
def publish_onion_peer():
if shared.onion_hostname:
logging.info('Publishing our onion peer')
obj = structure.OnionPeer(
shared.onion_hostname, shared.listening_port).to_object()
proofofwork.do_pow_and_publish(obj)

View File

@ -21,11 +21,17 @@ protocol_version = 3
services = 3 # NODE_NETWORK, NODE_SSL services = 3 # NODE_NETWORK, NODE_SSL
stream = 1 stream = 1
nonce = os.urandom(8) nonce = os.urandom(8)
user_agent = b'/MiNode:0.3.3/' user_agent = b'/MiNode:0.3.4/'
timeout = 600 timeout = 600
header_length = 24 header_length = 24
i2p_dest_obj_type = 0x493250 i2p_dest_obj_type = 0x493250
i2p_dest_obj_version = 1 i2p_dest_obj_version = 1
onion_obj_type = 0x746f72
onion_obj_version = 3
socks_proxy = None
tor = False
onion_hostname = ''
i2p_enabled = False i2p_enabled = False
i2p_transient = False i2p_transient = False
@ -59,6 +65,9 @@ i2p_core_nodes = set()
i2p_node_pool = set() i2p_node_pool = set()
i2p_unchecked_node_pool = set() i2p_unchecked_node_pool = set()
onion_pool = set()
onion_unchecked_pool = set()
outgoing_connections = 8 outgoing_connections = 8
connection_limit = 250 connection_limit = 250

View File

@ -1,8 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Protocol structures""" """Protocol structures"""
import base64 import base64
import binascii
import hashlib import hashlib
import logging import logging
import re
import socket import socket
import struct import struct
import time import time
@ -223,3 +225,39 @@ class NetAddr():
stream, net_addr = struct.unpack('>QI26s', b)[1:] stream, net_addr = struct.unpack('>QI26s', b)[1:]
n = NetAddrNoPrefix.from_bytes(net_addr) n = NetAddrNoPrefix.from_bytes(net_addr)
return cls(n.services, n.host, n.port, stream) return cls(n.services, n.host, n.port, stream)
class OnionPeer():
def __init__(self, host, port=8444, stream=None, dest_pub=None):
self.stream = stream or shared.stream
self.host = host
self.port = port
try:
self.dest_pub = dest_pub or base64.b32decode(
re.search(r'(.*)\.onion', host).groups()[0], True)
except (AttributeError, binascii.Error) as e:
raise ValueError('Malformed hostname') from e
def __repr__(self):
return 'onion_peer, stream: {}, host: {}, port {}'.format(
self.stream, self.host, self.port)
def to_object(self):
payload = b''
payload += VarInt(self.port).to_bytes()
payload += b'\xfd\x87\xd8\x7e\xeb\x43'
payload += self.dest_pub
return Object(
b'\x00' * 8, int(time.time() + 8 * 3600), shared.onion_obj_type,
shared.onion_obj_version, self.stream, payload)
@classmethod
def from_object(cls, obj):
payload = obj.object_payload
port_length = VarInt.length(payload[0])
port = VarInt.from_bytes(payload[:port_length]).n
if payload[port_length:port_length + 6] != b'\xfd\x87\xd8\x7e\xeb\x43':
raise ValueError('Malformed onion peer object')
dest_pub = payload[port_length + 6:]
host = base64.b32encode(dest_pub).lower().decode() + '.onion'
return cls(host, port, obj.stream_number, dest_pub)

7
minode/tests/common.py Normal file
View File

@ -0,0 +1,7 @@
import socket
try:
socket.socket().bind(('127.0.0.1', 9050))
tor_port_free = True
except (OSError, socket.error):
tor_port_free = False

View File

@ -13,6 +13,8 @@ import psutil
from minode.i2p import util from minode.i2p import util
from minode.structure import NetAddrNoPrefix from minode.structure import NetAddrNoPrefix
from .common import tor_port_free
try: try:
socket.socket().bind(('127.0.0.1', 7656)) socket.socket().bind(('127.0.0.1', 7656))
i2p_port_free = True i2p_port_free = True
@ -185,3 +187,21 @@ class TestProcessI2P(TestProcess):
class TestProcessNoI2P(TestProcessShutdown): class TestProcessNoI2P(TestProcessShutdown):
"""Test minode process shutdown with --i2p and no IP""" """Test minode process shutdown with --i2p and no IP"""
_process_cmd = ['minode', '--i2p', '--no-ip'] _process_cmd = ['minode', '--i2p', '--no-ip']
@unittest.skipIf(tor_port_free, 'No running tor detected')
class TestProcessTor(TestProcessProto):
"""A test case for minode process running with tor enabled"""
_process_cmd = ['minode', '--tor']
_wait_time = 60
def test_connections(self):
"""Check minode process connections"""
for _ in range(self._wait_time):
time.sleep(0.5)
connections = self.connections()
for c in connections:
self.assertEqual(c.raddr[0], '127.0.0.1')
self.assertEqual(c.raddr[1], 9050)
if len(connections) > self._connection_limit / 2:
break

View File

@ -23,6 +23,9 @@ sample_addr_data = unhexlify(
sample_object_data = unhexlify( sample_object_data = unhexlify(
'000000000000000000000000652724030000002a010248454c4c4f') '000000000000000000000000652724030000002a010248454c4c4f')
sample_onion_host = \
'bmtestlmgmvpbsg7kzmrxu47chs3cdou2tj4t5iloocgujzsf3e7rbqd.onion'
logging.basicConfig( logging.basicConfig(
level=shared.log_level, level=shared.log_level,
format='[%(asctime)s] [%(levelname)s] %(message)s') format='[%(asctime)s] [%(levelname)s] %(message)s')
@ -192,3 +195,21 @@ class TestStructure(unittest.TestCase):
nonce, obj.expires_time, obj.object_type, obj.version, nonce, obj.expires_time, obj.object_type, obj.version,
obj.stream_number, obj.object_payload) obj.stream_number, obj.object_payload)
self.assertTrue(obj.is_valid()) self.assertTrue(obj.is_valid())
def test_onion_peer(self):
"""Make an onion peer object and decode it back"""
with self.assertRaises(ValueError):
onion_peer = structure.OnionPeer('testing2')
with self.assertRaises(ValueError):
onion_peer = structure.OnionPeer('testing.onion')
onion_peer = structure.OnionPeer(sample_onion_host)
self.assertEqual(onion_peer.stream, shared.stream)
obj = onion_peer.to_object()
self.assertEqual(obj.object_type, shared.onion_obj_type)
self.assertEqual(obj.version, shared.onion_obj_version)
decoded = structure.OnionPeer.from_object(obj)
self.assertEqual(decoded.dest_pub, onion_peer.dest_pub)
self.assertEqual(decoded.port, onion_peer.port)
obj.object_payload = obj.object_payload[0:1] + obj.object_payload[2:]
with self.assertRaises(ValueError):
structure.OnionPeer.from_object(obj)

64
minode/tests/test_tor.py Normal file
View File

@ -0,0 +1,64 @@
"""Tests for tor module"""
import collections
import os
import tempfile
import unittest
from minode import shared
from .common import tor_port_free
try:
from minode import tor
except ImportError:
tor = None
Proxy = collections.namedtuple('Proxy', ['hostname', 'port'])
@unittest.skipIf(
tor_port_free or tor is None, 'Inapropriate environment for tor service')
class TestTor(unittest.TestCase):
"""A test case running the tor service"""
tor = None
_files = ['onion_dest_priv.key', 'onion_dest.pub']
@classmethod
def cleanup(cls):
"""Remove used files"""
for f in cls._files:
try:
os.remove(os.path.join(shared.data_directory, f))
except FileNotFoundError:
pass
@classmethod
def setUpClass(cls):
shared.data_directory = tempfile.gettempdir()
shared.socks_proxy = Proxy('127.0.0.1', 9050)
cls.cleanup()
@classmethod
def tearDownClass(cls):
if cls.tor:
cls.tor.close()
cls.cleanup()
def test_tor(self):
"""Start the tor service as in main and check the environment"""
self.tor = tor.start_tor_service()
if not self.tor:
self.fail('The tor service has hot started.')
with open(
os.path.join(shared.data_directory, 'onion_dest.pub'),
'r', encoding='ascii'
) as key_file:
onion_key = key_file.read()
self.assertEqual(onion_key + '.onion', shared.onion_hostname)
# with open(
# os.path.join(shared.data_directory, 'onion_dest_priv.key'), 'rb'
# ) as key_file:
# private_key = key_file.read()

147
minode/tor.py Normal file
View File

@ -0,0 +1,147 @@
"""Tor specific procedures"""
import logging
import os
import stat
import random
import tempfile
import stem
import stem.control
import stem.process
import stem.util
import stem.version
from . import shared
def logwrite(line):
"""A simple log writing handler for tor messages"""
try:
level, line = line.split('[', 1)[1].split(']', 1)
except (IndexError, ValueError):
logging.warning(line)
else:
if level in ('err', 'warn'):
logging.info('(tor)%s', line)
def start_tor_service():
"""Start own tor instance and configure a hidden service"""
try:
socket_dir = os.path.join(shared.data_directory, 'tor')
os.makedirs(socket_dir, exist_ok=True)
except OSError:
try:
socket_dir = tempfile.mkdtemp()
except OSError:
logging.info('Failed to create a temp dir.')
return
if os.getuid() == 0:
logging.info('Tor is not going to start as root')
return
try:
present_permissions = os.stat(socket_dir)[0]
disallowed_permissions = stat.S_IRWXG | stat.S_IRWXO
allowed_permissions = ((1 << 32) - 1) ^ disallowed_permissions
os.chmod(socket_dir, allowed_permissions & present_permissions)
except OSError:
logging.debug('Failed to set dir permissions.')
return
stem.util.log.get_logger().setLevel(logging.WARNING)
control_socket = os.path.abspath(os.path.join(socket_dir, 'tor_control'))
port = str(shared.socks_proxy.port)
tor_config = {
'SocksPort': port,
'ControlSocket': control_socket}
for attempt in range(20):
if attempt > 0:
port = random.randint(32767, 65535) # nosec B311
tor_config['SocksPort'] = str(port)
try:
stem.process.launch_tor_with_config(
tor_config, take_ownership=True, timeout=90,
init_msg_handler=logwrite)
except OSError:
if not attempt:
if not shared.listen_for_connections:
return
try:
stem.version.get_system_tor_version()
except IOError:
return
continue
else:
logging.info('Started tor on port %s', port)
break
else:
logging.debug('Failed to start tor.')
return
if not shared.listen_for_connections:
return True
try:
controller = stem.control.Controller.from_socket_file(control_socket)
controller.authenticate()
except stem.SocketError:
logging.debug('Failed to instantiate or authenticate on controller.')
return
onionkey = onionkeytype = None
try:
with open(
os.path.join(shared.data_directory, 'onion_dest_priv.key'),
'r', encoding='ascii'
) as src:
onionkey = src.read()
logging.debug('Loaded onion service private key.')
onionkeytype = 'ED25519-V3'
except FileNotFoundError:
pass
except Exception:
logging.info(
'Error while loading onion service private key.', exc_info=True)
response = controller.create_ephemeral_hidden_service(
shared.listening_port, key_type=onionkeytype or 'NEW',
key_content=onionkey or 'BEST'
)
if not response.is_ok():
logging.info('Bad response from controller ):')
return
shared.onion_hostname = '{}.onion'.format(response.service_id)
logging.info('Started hidden service %s', shared.onion_hostname)
if onionkey:
return controller
try:
with open(
os.path.join(shared.data_directory, 'onion_dest_priv.key'),
'w', encoding='ascii'
) as src:
src.write(response.private_key)
logging.debug('Saved onion service private key.')
except Exception:
logging.warning(
'Error while saving onion service private key.', exc_info=True)
try:
with open(
os.path.join(shared.data_directory, 'onion_dest.pub'),
'w', encoding='ascii'
) as src:
src.write(response.service_id)
logging.debug('Saved onion service public key.')
except Exception:
logging.warning(
'Error while saving onion service public key.', exc_info=True)
return controller

View File

@ -1,2 +1,4 @@
coverage coverage
psutil psutil
PySocks
stem

View File

@ -24,6 +24,7 @@ setup(
packages=find_packages(exclude=('*tests',)), packages=find_packages(exclude=('*tests',)),
package_data={'': ['*.csv', 'tls/*.pem']}, package_data={'': ['*.csv', 'tls/*.pem']},
entry_points={'console_scripts': ['minode = minode.main:main']}, entry_points={'console_scripts': ['minode = minode.main:main']},
extras_require={'proxy': ['PySocks'], 'tor': ['PySocks', 'stem>1.8.0']},
classifiers=[ classifiers=[
"License :: OSI Approved :: MIT License" "License :: OSI Approved :: MIT License"
"Operating System :: OS Independent", "Operating System :: OS Independent",

View File

@ -3,6 +3,8 @@ envlist = reset,py3{6,7,8,9,10,11},stats
skip_missing_interpreters = true skip_missing_interpreters = true
[testenv] [testenv]
setenv =
HOME = {envtmpdir}
deps = -rrequirements.txt deps = -rrequirements.txt
commands = commands =
coverage run -a -m tests coverage run -a -m tests