Add tor v3 addresses support

This commit is contained in:
Christophe Mehay 2019-04-03 16:11:09 +02:00 committed by Christophe Mehay
parent 8d562ed2e6
commit d3252e276e
24 changed files with 1086 additions and 225 deletions

View file

@ -7,11 +7,13 @@ secret_env:
- '*_KEY'
- '*_PORTS'
- '*_SERVICE_NAME'
- '*_TOR_SERVICE_*'
pre_conf_commands:
- onions --setup-hosts
post_conf_commands:
- chmod -R 700 $HOME
- chown -R tor:tor $HOME
reload:

View file

@ -3,6 +3,7 @@ import argparse
import logging
import os
import sys
from base64 import b64decode
from json import dumps
from re import match
@ -46,6 +47,10 @@ class Setup(object):
assert len(key) > 800
self.setup[host]['key'] = key
def _load_keys_in_services(self):
for service in self.services:
service.load_key()
def _get_service(self, host, service):
self._add_host(host)
self.setup[host]['service'] = service
@ -66,30 +71,39 @@ class Setup(object):
if service:
return service
def add_empty_group(self, name):
def add_empty_group(self, name, version=None):
if self.find_group_by_name(name):
raise Exception('Group {name} already exists'.format(name=name))
group = ServicesGroup(name=name)
group = ServicesGroup(name=name, version=version)
self.services.append(group)
return group
def add_new_service(self, host, name=None, ports=None, key=None):
def add_new_service(self,
host,
name=None,
ports=None,
key=None):
group = self.find_group_by_name(name)
service = self.find_service_by_host(host)
if group:
service = group.get_service_by_host(host)
else:
service = self.find_service_by_host(host)
if not service:
service = Service(host=host)
if not group:
group = ServicesGroup(
service=service,
name=name,
hidden_service_dir=self.hidden_service_dir
hidden_service_dir=self.hidden_service_dir,
)
else:
group.add_service(service)
if group not in self.services:
self.services.append(group)
elif group and service not in group.services:
group.add_service(service)
else:
group = self.find_group_by_service(service)
self.find_group_by_service(service)
if key:
group.add_key(key)
if ports:
@ -108,22 +122,69 @@ class Setup(object):
self.add_new_service(host=host, ports=ports)
def _set_key(self, host, key):
self.add_new_service(host=host, key=key)
self.add_new_service(host=host, key=key.encode())
def _setup_from_env(self):
match_map = (
(r'([A-Z0-9]*)_PORTS', self._set_ports),
(r'([A-Z0-9]*)_KEY', self._set_key),
)
for key, val in os.environ.items():
for reg, call in match_map:
def _setup_from_env(self, match_map):
for reg, call in match_map:
for key, val in os.environ.items():
m = match(reg, key)
if m:
call(m.groups()[0].lower(), val)
def _setup_keys_and_ports_from_env(self):
self._setup_from_env(
(
(r'([A-Z0-9]+)_PORTS', self._set_ports),
(r'([A-Z0-9]+)_KEY', self._set_key),
)
)
def get_or_create_empty_group(self, name, version=None):
group = self.find_group_by_name(name)
if group:
if version:
group.set_version(version)
return group
return self.add_empty_group(name, version)
def _set_group_version(self, name, version):
'Setup groups with version'
group = self.get_or_create_empty_group(name, version=version)
group.set_version(version)
def _set_group_key(self, name, key):
'Set key for service group'
group = self.get_or_create_empty_group(name)
if group.version == 3:
group.add_key(b64decode(key))
else:
group.add_key(key)
def _set_group_hosts(self, name, hosts):
'Set services for service groups'
self.get_or_create_empty_group(name)
for host_map in hosts.split(','):
host_map = host_map.strip()
port_from, host, port_dest = host_map.split(':', 2)
if host == 'unix' and port_dest.startswith('/'):
self.add_new_service(host=name, name=name, ports=host_map)
else:
ports = '{frm}:{dst}'.format(frm=port_from, dst=port_dest)
self.add_new_service(host=host, name=name, ports=ports)
def _setup_services_from_env(self):
self._setup_from_env(
(
(r'([A-Z0-9]+)_TOR_SERVICE_VERSION', self._set_group_version),
(r'([A-Z0-9]+)_TOR_SERVICE_KEY', self._set_group_key),
(r'([A-Z0-9]+)_TOR_SERVICE_HOSTS', self._set_group_hosts),
)
)
def _get_setup_from_env(self):
self._set_service_names()
self._setup_from_env()
self._setup_keys_and_ports_from_env()
self._setup_services_from_env()
def _get_setup_from_links(self):
containers = DockerLinks().to_containers()
@ -162,6 +223,7 @@ class Setup(object):
self.setup = {}
self._get_setup_from_env()
self._get_setup_from_links()
self._load_keys_in_services()
self.check_services()
self.apply_conf()
@ -201,38 +263,65 @@ class Onions(Setup):
def torrc_parser(self):
self.torrc_dict = {}
def parse_dir(line):
_, path = line.split()
group_name = os.path.basename(path)
group = (self.find_group_by_name(group_name)
or self.add_empty_group(group_name))
return group
self.torrc_dict[group_name] = {
'services': [],
}
return group_name
def parse_port(line, service_group):
def parse_port(line, name):
_, port_from, dest = line.split()
service_host, port = dest.split(':')
ports_str = '{port_from}:{dest}'
name = service_host
ports_param = ports_str.format(port_from=port_from,
dest=port)
if port.startswith('/'):
name = service_group.name
service_host = name
ports_param = ports_str.format(port_from=port_from,
dest=dest)
service = (service_group.get_service_by_host(name)
or Service(name))
service.add_ports(ports_param)
if service not in service_group.services:
service_group.add_service(service)
self.torrc_dict[name]['services'].append({
'host': service_host,
'ports': ports_param,
})
def parse_version(line, name):
_, version = line.split()
self.torrc_dict[name]['version'] = int(version)
def setup_services():
for name, setup in self.torrc_dict.items():
version = setup.get('version', 2)
group = (self.find_group_by_name(name)
or self.add_empty_group(name, version=version))
for service_dict in setup.get('services', []):
host = service_dict['host']
service = (group.get_service_by_host(host)
or Service(host))
service.add_ports(service_dict['ports'])
if service not in group.services:
group.add_service(service)
self._load_keys_in_services()
if not os.path.exists(self.torrc):
return
with open(self.torrc, 'r') as f:
for line in f.readlines():
if line.startswith('HiddenServiceDir'):
service_group = parse_dir(line)
if line.startswith('HiddenServicePort'):
parse_port(line, service_group)
try:
with open(self.torrc, 'r') as f:
for line in f.readlines():
if line.startswith('HiddenServiceDir'):
name = parse_dir(line)
if line.startswith('HiddenServicePort'):
parse_port(line, name)
if line.startswith('HiddenServiceVersion'):
parse_version(line, name)
except BaseException:
raise Exception(
'Fail to parse torrc file. Please check the file'
)
setup_services()
def __str__(self):
if not self.services:
@ -257,6 +346,7 @@ def main():
help='Setup hosts')
args = parser.parse_args()
logging.getLogger().setLevel(logging.WARNING)
try:
onions = Onions()
if args.setup:
@ -264,6 +354,7 @@ def main():
else:
onions.torrc_parser()
except BaseException as e:
logging.exception(e)
error_msg = str(e)
else:
error_msg = None
@ -271,7 +362,6 @@ def main():
if error_msg:
print(dumps({'error': error_msg}))
sys.exit(1)
logging.getLogger().setLevel(logging.ERROR)
print(onions.to_json())
else:
if error_msg:

View file

@ -1,42 +1,67 @@
'This class define a service link'
import base64
import binascii
import logging
import os
import pathlib
import re
from base64 import b32encode
from hashlib import sha1
from Crypto.PublicKey import RSA
from pytor import OnionV2
from pytor import OnionV3
class ServicesGroup(object):
name = None
_priv_key = None
_key_in_secrets = False
version = None
imported_key = False
_default_version = 2
_imported_key = False
_onion = None
_hidden_service_dir = "/var/lib/tor/hidden_service/"
hidden_service_dir = "/var/lib/tor/hidden_service/"
def __init__(self, name=None, service=None, hidden_service_dir=None):
def __init__(self,
name=None,
service=None,
version=None,
hidden_service_dir=None):
name_regex = r'^[a-zA-Z0-9-_]+$'
self.hidden_service_dir = hidden_service_dir or self.hidden_service_dir
self.onion_map = {
2: OnionV2,
3: OnionV3,
}
if not name and not service:
raise Exception(
'Init service group with a name or service at least'
)
self.services = []
self.name = name or service.host
if hidden_service_dir:
self._hidden_service_dir = hidden_service_dir
if not re.match(name_regex, self.name):
raise Exception(
'Group {name} has invalid name'.format(name=self.name)
)
if service:
self.add_service(service)
self.set_version(version or self._default_version)
self.gen_key()
self.load_key()
if not self._priv_key:
self.gen_key()
def set_version(self, version):
version = int(version)
if version not in self.onion_map:
raise Exception(
'Url version {version} is not supported'.format(version)
)
self.version = version
self._onion = self.onion_map[version]()
@property
def hidden_service_dir(self):
return os.path.join(self._hidden_service_dir, self.name)
def add_service(self, service):
if service not in self.services:
@ -50,15 +75,22 @@ class ServicesGroup(object):
return service
def add_key(self, key):
if self._key_in_secrets:
if self._imported_key:
logging.warning('Secret key already set, overriding')
self._priv_key = key
self._key_in_secrets = False
# Try to decode key from base64 encoding
# import the raw data if the input cannot be decoded as base64
try:
key = base64.b64decode(key)
except binascii.Error:
pass
self._onion.set_private_key(key)
self._imported_key = True
def __iter__(self):
yield 'name', self.name
yield 'onion', self.onion_url
yield 'urls', list(self.urls)
yield 'version', self.version
def __str__(self):
return '{name}: {urls}'.format(name=self.name,
@ -66,16 +98,7 @@ class ServicesGroup(object):
@property
def onion_url(self):
"Get onion url from private key"
# Convert private RSA to public DER
priv = RSA.importKey(self._priv_key.strip())
der = priv.publickey().exportKey("DER")
# hash key, keep first half of sha1, base32 encode
onion = b32encode(sha1(der[22:]).digest()[:10])
return '{onion}.onion'.format(onion=onion.decode().lower())
return self._onion.onion_hostname
@property
def urls(self):
@ -88,30 +111,17 @@ class ServicesGroup(object):
'Write key on disk and set tor service'
if not hidden_service_dir:
hidden_service_dir = self.hidden_service_dir
serv_dir = os.path.join(hidden_service_dir, self.name)
os.makedirs(serv_dir, exist_ok=True)
os.chmod(serv_dir, 0o700)
with open(os.path.join(serv_dir, 'private_key'), 'w') as f:
f.write(self._priv_key)
os.fchmod(f.fileno(), 0o600)
with open(os.path.join(serv_dir, 'hostname'), 'w') as f:
f.write(self.onion_url)
if not os.path.isdir(hidden_service_dir):
pathlib.Path(hidden_service_dir).mkdir(parents=True)
self._onion.write_hidden_service(hidden_service_dir, force=True)
def _load_key(self, key_file):
if os.path.exists(key_file):
with open(key_file, 'r') as f:
key = f.read().encode()
if not len(key):
return
try:
rsa = RSA.importKey(key)
self._priv_key = rsa.exportKey("PEM").decode()
except BaseException:
raise('Fail to load key for {name} services'.format(
name=self.name
))
with open(key_file, 'rb') as f:
self._onion.set_private_key_from_file(f)
def load_key(self):
def load_key(self, override=False):
if self._imported_key and not override:
return
self.load_key_from_secrets()
self.load_key_from_conf()
@ -122,8 +132,9 @@ class ServicesGroup(object):
return
try:
self._load_key(secret_file)
self._key_in_secrets = True
except BaseException:
self._imported_key = True
except BaseException as e:
logging.exception(e)
logging.warning('Fail to load key from secret, '
'check the key or secret name collision')
@ -131,16 +142,17 @@ class ServicesGroup(object):
'Load key from disk if exists'
if not hidden_service_dir:
hidden_service_dir = self.hidden_service_dir
key_file = os.path.join(hidden_service_dir,
self.name,
'private_key')
self._load_key(key_file)
if not os.path.isdir(hidden_service_dir):
return
self._onion.load_hidden_service(hidden_service_dir)
def gen_key(self):
'Generate new 1024 bits RSA key for hidden service'
self._priv_key = RSA.generate(
bits=1024,
).exportKey("PEM").decode()
self.imported_key = False
return self._onion.gen_new_private_key()
@property
def _priv_key(self):
return self._onion.get_private_key()
class Ports:

View file

@ -6,7 +6,7 @@ from setuptools import setup
setup(
name='onions',
version='0.4.1',
version='0.5.0',
packages=find_packages(),
@ -31,10 +31,9 @@ setup(
"Topic :: System :: Installation/Setup",
],
install_requires=['pyentrypoint==0.5.1',
'Jinja2>=2.8',
'pycrypto', ],
install_requires=['pyentrypoint==0.5.2',
'Jinja2>=2.10',
'pytor>=0.1.2'],
entry_points={
'console_scripts': [
'onions = onions:main',

View file

@ -2,6 +2,7 @@ import json
import os
import re
from base64 import b32encode
from base64 import b64decode
from hashlib import sha1
import pytest
@ -9,8 +10,9 @@ from Crypto.PublicKey import RSA
from onions import Onions
def get_key_and_onion():
key = '''
def get_key_and_onion(version=2):
key = {}
key[2] = '''
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCsMP4gl6g1Q313miPhb1GnDr56ZxIWGsO2PwHM1infkbhlBakR
6DGQfpE31L1ZKTUxY0OexKbW088v8qCOfjD9Zk1i80JP4xzfWQcwFZ5yM/0fkhm3
@ -27,24 +29,40 @@ La/7Syrnobngsh/vX90CQB+PSSBqiPSsK2yPz6Gsd6OLCQ9sdy2oRwFTasH8sZyl
bhJ3M9WzP/EMkAzyW8mVs1moFp3hRcfQlZHl6g1U9D8=
-----END RSA PRIVATE KEY-----
'''
onion = b32encode(
onion = {}
pub = {}
onion[2] = b32encode(
sha1(
RSA.importKey(
key.strip()
key[2].strip()
).publickey().exportKey(
"DER"
)[22:]
).digest()[:10]
).decode().lower() + '.onion'
return key.strip(), onion
key[3] = '''
PT0gZWQyNTUxOXYxLXNlY3JldDogdHlwZTAgPT0AAACArobDQYyZAWXei4QZwr++j96H1X/gq14N
wLRZ2O5DXuL0EzYKkdhZSILY85q+kfwZH8z4ceqe7u1F+0pQi/sM
'''
pub[3] = '''
PT0gZWQyNTUxOXYxLXB1YmxpYzogdHlwZTAgPT0AAAC9kzftiea/kb+TWlCEVNpfUJLVk+rFIoMG
m9/hW13isA==
'''
onion[3] = 'xwjtp3mj427zdp4tljiiivg2l5ijfvmt5lcsfaygtpp6cw254kykvpyd.onion'
return key[version].strip(), onion[version]
def get_torrc_template():
return r'''
{% for service_group in services %}
HiddenServiceDir /var/lib/tor/hidden_service/{{service_group.name}}
HiddenServiceDir {{service_group.hidden_service_dir}}
{% if service_group.version == 3 %}
HiddenServiceVersion 3
{% endif %}
{% for service in service_group.services %}
{% for port in service.ports %}
{% if port.is_socket %}
@ -155,7 +173,7 @@ ff02::2 ip6-allrouters
172.17.0.2 compose_service1_1 bf447f22cdba
'''.strip()
fs.CreateFile('/etc/hosts', contents=etc_host)
fs.create_file('/etc/hosts', contents=etc_host)
monkeypatch.setattr(os, 'environ', env)
@ -190,34 +208,57 @@ def test_key(monkeypatch):
assert onion.services[0].onion_url == onion_url
def test_key_in_secret(fs, monkeypatch):
def test_key_v3(monkeypatch):
key, onion_url = get_key_and_onion(version=3)
env = {
'SERVICE1_SERVICE_NAME': 'group1',
'SERVICE2_SERVICE_NAME': 'group1',
'SERVICE3_SERVICE_NAME': 'group2',
'SERVICE1_PORTS': '80:80',
'SERVICE2_PORTS': '81:80,82:8000',
'SERVICE3_PORTS': '80:unix://unix.socket',
'GROUP1_TOR_SERVICE_HOSTS': '80:service1:80,81:service2:80',
'GROUP1_TOR_SERVICE_VERSION': '3',
'GROUP1_TOR_SERVICE_KEY': key,
}
monkeypatch.setattr(os, 'environ', env)
key, onion_url = get_key_and_onion()
onion = Onions()
onion._get_setup_from_env()
onion._load_keys_in_services()
fs.CreateFile('/run/secrets/group1', contents=key)
assert len(os.environ) == 3
assert len(onion.services) == 1
assert onion.services[0].onion_url == onion_url
def test_key_in_secret(fs, monkeypatch):
env = {
'GROUP1_TOR_SERVICE_HOSTS': '80:service1:80',
'GROUP2_TOR_SERVICE_HOSTS': '80:service2:80',
'GROUP3_TOR_SERVICE_HOSTS': '80:service3:80',
'GROUP3_TOR_SERVICE_VERSION': '3',
}
monkeypatch.setattr(os, 'environ', env)
key_v2, onion_url_v2 = get_key_and_onion()
key_v3, onion_url_v3 = get_key_and_onion(version=3)
fs.create_file('/run/secrets/group1', contents=key_v2)
fs.create_file('/run/secrets/group3', contents=b64decode(key_v3))
onion = Onions()
onion._get_setup_from_env()
onion._load_keys_in_services()
group1 = onion.find_group_by_name('group1')
group2 = onion.find_group_by_name('group2')
group3 = onion.find_group_by_name('group3')
# assert group._priv_key == key
assert group1.onion_url == onion_url
assert group2.onion_url != onion_url
assert group1.onion_url == onion_url_v2
assert group2.onion_url not in [onion_url_v2, onion_url_v3]
assert group3.onion_url == onion_url_v3
def test_configuration(fs, monkeypatch):
def test_configuration(fs, monkeypatch, tmpdir):
env = {
'SERVICE1_SERVICE_NAME': 'group1',
'SERVICE2_SERVICE_NAME': 'group1',
@ -225,44 +266,72 @@ def test_configuration(fs, monkeypatch):
'SERVICE1_PORTS': '80:80',
'SERVICE2_PORTS': '81:80,82:8000',
'SERVICE3_PORTS': '80:unix://unix.socket',
'GROUP3_TOR_SERVICE_VERSION': '2',
'GROUP3_TOR_SERVICE_HOSTS': '80:service4:888,81:service5:8080',
'GROUP4_TOR_SERVICE_VERSION': '3',
'GROUP4_TOR_SERVICE_HOSTS': '81:unix://unix2.sock',
'GROUP3V3_TOR_SERVICE_VERSION': '3',
'GROUP3V3_TOR_SERVICE_HOSTS': '80:service4:888,81:service5:8080',
'SERVICE5_TOR_SERVICE_HOSTS': '80:service5:80'
}
hidden_dir = '/var/lib/tor/hidden_service'
monkeypatch.setattr(os, 'environ', env)
monkeypatch.setattr(os, 'fchmod', lambda x, y: None)
key, onion_url = get_key_and_onion()
torrc_tpl = get_torrc_template()
fs.CreateFile('/var/local/tor/torrc.tpl', contents=torrc_tpl)
fs.CreateFile('/etc/tor/torrc')
fs.create_file('/var/local/tor/torrc.tpl', contents=torrc_tpl)
fs.create_file('/etc/tor/torrc')
fs.create_dir(hidden_dir)
onion = Onions()
onion._get_setup_from_env()
onion._load_keys_in_services()
onion.apply_conf()
onions_urls = {}
for dir in os.listdir(hidden_dir):
with open(os.path.join(hidden_dir, dir, 'hostname'), 'r') as f:
onions_urls[dir] = f.read().strip()
with open('/etc/tor/torrc', 'r') as f:
torrc = f.read()
print(torrc)
assert 'HiddenServiceDir /var/lib/tor/hidden_service/group1' in torrc
assert 'HiddenServicePort 80 service1:80' in torrc
assert 'HiddenServicePort 81 service2:80' in torrc
assert 'HiddenServicePort 82 service2:8000' in torrc
assert 'HiddenServiceDir /var/lib/tor/hidden_service/group2' in torrc
assert 'HiddenServicePort 80 unix://unix.socket' in torrc
assert 'HiddenServiceDir /var/lib/tor/hidden_service/group3' in torrc
assert 'HiddenServiceDir /var/lib/tor/hidden_service/group4' in torrc
assert 'HiddenServiceDir /var/lib/tor/hidden_service/group3v3' in torrc
assert 'HiddenServiceDir /var/lib/tor/hidden_service/service5' in torrc
assert torrc.count('HiddenServicePort 80 service4:888') == 2
assert torrc.count('HiddenServicePort 81 service5:8080') == 2
assert torrc.count('HiddenServicePort 80 service5:80') == 1
assert torrc.count('HiddenServicePort 81 unix://unix2.sock') == 1
assert torrc.count('HiddenServiceVersion 3') == 2
# Check parser
onion2 = Onions()
onion2.torrc_parser()
assert len(onion2.services) == 2
assert len(onion2.services) == 6
assert set(
group.name for group in onion2.services
) == set(['group1', 'group2'])
# ) == set(['group1', 'group2'])
) == set(['group1', 'group2', 'group3', 'group4', 'group3v3', 'service5'])
for group in onion2.services:
if group.name == 'group1':
assert len(group.services) == 2
assert group.version == 2
assert group.onion_url == onions_urls[group.name]
assert set(
service.host for service in group.services
) == set(['service1', 'service2'])
@ -279,6 +348,8 @@ def test_configuration(fs, monkeypatch):
) == set([(81, 80), (82, 8000)])
if group.name == 'group2':
assert len(group.services) == 1
assert group.version == 2
assert group.onion_url == onions_urls[group.name]
assert set(
service.host for service in group.services
) == set(['group2'])
@ -288,6 +359,53 @@ def test_configuration(fs, monkeypatch):
(port.port_from, port.dest) for port in service.ports
) == set([(80, 'unix://unix.socket')])
if group.name in ['group3', 'group3v3']:
assert len(group.services) == 2
assert group.version == 2 if group.name == 'group3' else 3
assert group.onion_url == onions_urls[group.name]
assert set(
service.host for service in group.services
) == set(['service4', 'service5'])
for service in group.services:
if service.host == 'service4':
assert len(service.ports) == 1
assert set(
(port.port_from, port.dest) for port in service.ports
) == set([(80, 888)])
if service.host == 'service5':
assert len(service.ports) == 1
assert set(
(port.port_from, port.dest) for port in service.ports
) == set([(81, 8080)])
if group.name == 'group4':
assert len(group.services) == 1
assert group.version == 3
assert group.onion_url == onions_urls[group.name]
assert set(
service.host for service in group.services
) == set(['group4'])
for service in group.services:
assert service.host == 'group4'
assert len(service.ports) == 1
assert set(
(port.port_from, port.dest) for port in service.ports
) == set([(81, 'unix://unix2.sock')])
if group.name == 'service5':
assert len(group.services) == 1
assert group.version == 2
assert group.onion_url == onions_urls[group.name]
assert set(
service.host for service in group.services
) == set(['service5'])
for service in group.services:
assert service.host == 'service5'
assert len(service.ports) == 1
assert set(
(port.port_from, port.dest) for port in service.ports
) == set([(80, 80)])
def test_groups(monkeypatch):
env = {

View file

@ -1,5 +1,8 @@
{% for service_group in services %}
HiddenServiceDir /var/lib/tor/hidden_service/{{service_group.name}}
HiddenServiceDir {{service_group.hidden_service_dir}}
{% if service_group.version == 3 %}
HiddenServiceVersion 3
{% endif %}
{% for service in service_group.services %}
{% for port in service.ports %}
{% if port.is_socket %}