diff --git a/client/ubuntu-packages.txt b/client/ubuntu-packages.txt index 6058ae3..2725d4e 100644 --- a/client/ubuntu-packages.txt +++ b/client/ubuntu-packages.txt @@ -20,11 +20,11 @@ gnupg iptables login lvm2 +python3-virtualenv python3 python3-dev systemd tar ubuntu-release-upgrader-core update-notifier-common -virtualenv wireless-tools diff --git a/penguindome/client.py b/penguindome/client.py index 794ec71..eee7675 100644 --- a/penguindome/client.py +++ b/penguindome/client.py @@ -31,7 +31,8 @@ top_dir, ) -gpg_command = partial(main_gpg_command, with_user_id=True, +gpg_user_id = 'penguindome-client' +gpg_command = partial(main_gpg_command, '-u', gpg_user_id, minimum_version=client_gpg_version) session = None @@ -79,7 +80,7 @@ def server_request(cmd, data=None, data_path=None, exit_on_connection_error=False, logger=None, # Clients should never need to use these. They are for # internal use on the server. - local_port=None, signed=True): + local_port=None, signed=True, useServerKeychain=False): global session if session is None: session = requests.Session() @@ -98,10 +99,21 @@ def server_request(cmd, data=None, data_path=None, data_path = temp_data_file.name post_data = {'data': data} if signed: - gpg_command('--armor', '--detach-sign', '-o', signature_file.name, - data_path, log=logger) - signature_file.seek(0) - post_data['signature'] = signature_file.read() + if useServerKeychain: + main_gpg_command( + '-u', 'penguindome-server', + '--armor', '--detach-sign', '-o', signature_file.name, + data_path, log=logger, minimum_version=client_gpg_version + ) + signature_file.seek(0) + post_data['signature'] = signature_file.read() + else: + gpg_command( + '--armor', '--detach-sign', '-o', signature_file.name, + data_path, log=logger + ) + signature_file.seek(0) + post_data['signature'] = signature_file.read() kwargs = { 'data': post_data, diff --git a/penguindome/penguindome.py b/penguindome/penguindome.py index a1adfb2..f6df9a8 100644 --- a/penguindome/penguindome.py +++ b/penguindome/penguindome.py @@ -21,6 +21,8 @@ from itertools import chain import logbook import os +import pwd +import sys import pickle import re import socket @@ -52,10 +54,6 @@ client_gpg_version = '2.1.11' server_pipe_log_filter_re = re.compile( r'POST.*/server_pipe/.*/(?:send|receive).* 200 ') -gpg_user_ids = { - 'client': 'penguindome-client', - 'server': 'penguindome-server', -} SelectorVariants = namedtuple( 'SelectorVariants', ['plain_mongo', 'plain_mem', 'enc_mongo', 'enc_mem']) @@ -105,14 +103,21 @@ def set_gpg(mode): mode)) os.environ['GNUPGHOME'] = home - os.chmod(home, 0o0700) + try: + os.chmod(home, 0o0700) + except PermissionError: + print("It looks like you are trying to run the debug server as " + + "your user, please run with `sudo -u " + + pwd.getpwuid(os.stat(top_dir).st_uid).pw_name + "`" + ) + sys.exit(1) # random seed gets corrupted sometimes because we're copying keyring from # server to client list(map(os.unlink, glob.glob(os.path.join(home, "random_seed*")))) gpg_mode = mode -def gpg_command(*cmd, with_user_id=False, with_trustdb=False, quiet=True, +def gpg_command(*cmd, with_trustdb=False, quiet=True, minimum_version='2.1.15', log=None): global gpg_exe, gpg_exe @@ -124,12 +129,28 @@ def gpg_command(*cmd, with_user_id=False, with_trustdb=False, quiet=True, ('gpg2', '--version'), stderr=subprocess.STDOUT).decode('utf8') except Exception: - output = subprocess.check_output( - ('gpg', '--version'), - stderr=subprocess.STDOUT).decode('utf8') - gpg_exe = 'gpg' + try: + output = subprocess.check_output( + ('gpg', '--version'), + stderr=subprocess.STDOUT).decode('utf8') + except Exception: + try: + # We know we installed gpg during the server install + # so chances are the user running this just doent have + # PATH configured right + output = subprocess.check_output( + ('/usr/bin/gpg', '--version'), + stderr=subprocess.STDOUT).decode('utf8') + except Exception: + raise Exception("No gpg application found. " + + "verify gpg or gpg2 is installed!") + else: + gpg_exe = '/usr/bin/gpg' + else: + gpg_exe = "gpg" else: - gpg_exe = 'gpg2' + gpg_exe = "gpg2" + match = re.search(r'^gpg \(GnuPG\) (\d+(?:\.\d+(?:\.\d+)?)?)', output, re.MULTILINE) if not match: @@ -145,18 +166,13 @@ def gpg_command(*cmd, with_user_id=False, with_trustdb=False, quiet=True, else: trustdb_args = ('--trust-model', 'always') - if with_user_id: - user_id_args = ('-u', gpg_user_ids[gpg_mode]) - else: - user_id_args = () - if quiet: quiet_args = ('--quiet',) else: quiet_args = () cmd = tuple(chain((gpg_exe, '--batch', '--yes'), quiet_args, trustdb_args, - user_id_args, cmd)) + cmd)) try: return subprocess.check_output(cmd, stderr=subprocess.STDOUT).\ decode('utf8') diff --git a/penguindome/server.py b/penguindome/server.py index ddcd634..675709d 100644 --- a/penguindome/server.py +++ b/penguindome/server.py @@ -32,7 +32,8 @@ top_dir, ) -gpg_command = partial(main_gpg_command, with_user_id=True) +gpg_user_id = 'penguindome-server' +gpg_command = partial(main_gpg_command, '-u', gpg_user_id) valid_client_parameters = ( # This is a list of other client which should be treated as the same as the @@ -121,6 +122,10 @@ def get_db(force_db=None): replicaset = get_setting('database:replicaset') if replicaset: kwargs['replicaset'] = replicaset + db_ssl_ca = get_setting('database:ssl_ca') + if db_ssl_ca: + kwargs['ssl'] = True + kwargs['ssl_ca_certs'] = db_ssl_ca connection = MongoClient(host, **kwargs) newdb = connection[database_name] diff --git a/penguindome/shell/__init__.py b/penguindome/shell/__init__.py index 7c39c6b..5529263 100644 --- a/penguindome/shell/__init__.py +++ b/penguindome/shell/__init__.py @@ -150,9 +150,10 @@ def close(self): class PenguinDomeServerPeer(InteractionPeer): def __init__(self, peer_type, pipe_id=None, local_port=None, logger=None, - client_hostname=None): + client_hostname=None, useServerKeychain=False): if peer_type not in ('client', 'server'): raise Exception('Invalid peer type "{}"'.format(peer_type)) + self.useServerKeychain = useServerKeychain self.type = peer_type self.pipe_id = pipe_id self.pending_data = b'' @@ -173,8 +174,16 @@ def __init__(self, peer_type, pipe_id=None, local_port=None, logger=None, else: response = self._request('open', data=data) self.encryptors = { - 'send': Encryptor(data['encryption_key'], data['encryption_iv']), - 'receive': Encryptor(data['encryption_key'], data['encryption_iv']) + 'send': + { + 'k': data['encryption_key'], + 'i': data['encryption_iv'] + }, + 'receive': + { + 'k': data['encryption_key'], + 'i': data['encryption_iv'] + } } def _request(self, request, data=None): @@ -187,7 +196,8 @@ def _request(self, request, data=None): response = server_request( '/penguindome/v1/server_pipe/{}/{}'.format(self.type, request), data=data, local_port=self.local_port, logger=self.logger, - signed=request not in ('send', 'receive')) + signed=request not in ('send', 'receive'), + useServerKeychain=self.useServerKeychain) if response.status_code == 404: raise FileNotFoundError('Pipe ID {} not found'.format( self.pipe_id)) @@ -203,7 +213,11 @@ def receive(self, timeout=None): def send(self, data): if self.done: raise EOFError() - encrypted_data = self.encryptors['send'].encrypt(data) + encrypted_data = Encryptor( + self.encryptors['send']['k'], + self.encryptors['send']['i'] + ).encrypt(data) + encoded_data = b64encode(encrypted_data).decode('utf8') data = self._request('send', {'data': encoded_data}) if 'eof' in data and data['eof']: @@ -229,7 +243,12 @@ def poll(self, timeout=None): if 'data' in data and data['data']: encoded_data = data['data'] encrypted_data = b64decode(encoded_data) - decrypted_data = self.encryptors['receive'].decrypt(encrypted_data) + + decrypted_data = Encryptor( + self.encryptors['receive']['k'], + self.encryptors['receive']['i'] + ).decrypt(encrypted_data) + self.pending_data += decrypted_data if 'eof' in data and data['eof']: self.done = True diff --git a/server/client_shell.py b/server/client_shell.py index fdff1cd..8371581 100644 --- a/server/client_shell.py +++ b/server/client_shell.py @@ -39,8 +39,8 @@ def main(): log.info('Requesting remote shell from {}', args.hostname) with PenguinDomeServerPeer( 'server', local_port=get_setting('local_port'), - logger=log, client_hostname=args.hostname) as remote, \ - TerminalPeer() as terminal: + logger=log, client_hostname=args.hostname, + useServerKeychain=True) as remote, TerminalPeer() as terminal: host = args.hostname script = '#!/bin/bash\npython client/endpoints/shell.py {}\n'.format( remote.pipe_id) diff --git a/server/initialize.py b/server/initialize.py index a7af6f7..18f6399 100644 --- a/server/initialize.py +++ b/server/initialize.py @@ -17,6 +17,7 @@ import glob import logbook import os +import pwd import random import subprocess import shutil @@ -28,7 +29,6 @@ top_dir, gpg_private_home, gpg_public_home, - gpg_user_ids, set_gpg, gpg_command, releases_dir, @@ -37,11 +37,13 @@ get_setting as get_client_setting, set_setting as set_client_setting, save_settings as save_client_settings, + gpg_user_id as client_user_id, ) from penguindome.server import ( get_setting as get_server_setting, set_setting as set_server_setting, save_settings as save_server_settings, + gpg_user_id as server_user_id, ) from penguindome.prompts import ( get_bool, @@ -198,10 +200,39 @@ def main(args): else: maybe_changed = maybe_changed_extended - generate_key('server', gpg_user_ids['server']) - generate_key('client', gpg_user_ids['client']) - import_key('server', gpg_user_ids['client']) - import_key('client', gpg_user_ids['server']) + # We will be running penguindome under a locked down account, to do so we + # need to create a system account, tell the service files to run penguin + # dome under that account, change the owner of the penguindome files to be + # owned by penguindome, but callable by the user, and make sure the web + # socket can be talked to by penguindome. + # + # before we do any of that though, we need to create the acount, and keep + # track of the user running the script as they will most likely be the one + # administering penguindome. since this installer needs to be ran as root, + # handle the condition where they use sudo! + if os.getenv("SUDO_USER"): + currentUserName = os.getenv("SUDO_USER") + else: + currentUserName = os.getenv("USER") + + penguinDomeUserName = "PenguinDomeSVC" + + if penguinDomeUserName not in [x.pw_name for x in pwd.getpwall()]: + try: + subprocess.check_output( + ('useradd', + '-r', + '-s', + '/bin/nologin', + penguinDomeUserName), + stderr=subprocess.STDOUT) + except Exception: + print("Could not create penguindome user!") + + generate_key('server', server_user_id) + generate_key('client', client_user_id) + import_key('server', client_user_id) + import_key('client', server_user_id) default = not (get_client_setting('loaded') and get_server_setting('loaded')) @@ -260,18 +291,46 @@ def main(args): break print('That file does not exist.') - server_changed |= maybe_changed('server', 'database:host', - get_string_or_list, - 'Database host:port:') + server_changed |= maybe_changed( + 'server', + 'database:host', + get_string_or_list, + 'Database host:port:' + ) + if get_server_setting('database:host'): server_changed |= maybe_changed( - 'server', 'database:replicaset', get_string_none, - 'Replicaset name:', empty_ok=True) - server_changed |= maybe_changed('server', 'database:name', - get_string, 'Database name:') - server_changed |= maybe_changed('server', 'database:username', - get_string_none, 'Database username:', - empty_ok=True) + 'server', + 'database:replicaset', + get_string_none, + 'Replicaset name:', + empty_ok=True + ) + + server_changed |= maybe_changed( + 'server', + 'database:name', + get_string, + 'Database name:' + ) + + server_changed |= maybe_changed( + 'server', + 'database:username', + get_string_none, + 'Database username:', + empty_ok=True + ) + + server_changed |= maybe_changed( + 'server', + 'database:ssl_ca', + get_string_none, + 'Database SSL CA file ' + + '(empty for none):', + empty_ok=True + ) + if get_server_setting('database:username'): server_changed |= maybe_changed('server', 'database:password', get_string, 'Database password:') @@ -320,73 +379,272 @@ def main(args): if client_changed: print('Saved client settings.') - service_file = '/etc/systemd/system/penguindome-server.service' - service_exists = os.path.exists(service_file) + nginx_site_file = '/etc/nginx/sites-enabled/penguindome' + service_exists = os.path.exists(nginx_site_file) default = not service_exists + do_redis = maybe_get_bool( + "do you want to configure redis?", default, args.yes) + if do_redis: + try: + subprocess.check_output( + ('sed', + '-i', + 's/supervised no/supervised systemd/', + '/etc/redis/redis.conf'), stderr=subprocess.STDOUT) + except Exception: + pass + + try: + subprocess.check_output( + ('systemctl', + 'daemon-reload'), stderr=subprocess.STDOUT) + except Exception: + pass + + try: + subprocess.check_output( + ('systemctl', + 'is-enabled', + 'redis'), stderr=subprocess.STDOUT) + except Exception: + try: + subprocess.check_output( + ('systemctl', + 'enable', + 'redis'), stderr=subprocess.STDOUT) + is_enabled = True + except Exception: + print("Error when enabling redis with systemd!") + else: + is_enabled = True + + if is_enabled: + try: + subprocess.check_output( + ('systemctl', + 'status', + 'redis'), stderr=subprocess.STDOUT) + except Exception: + if maybe_get_bool( + 'Do you want to start redis?', True, args.yes): + subprocess.check_output( + ('systemctl', + 'start', + 'redis'), stderr=subprocess.STDOUT) + else: + if maybe_get_bool( + 'Do you want to restart redis?', + server_changed, args.yes): + subprocess.check_output( + ('systemctl', + 'restart', + 'redis'), stderr=subprocess.STDOUT + ) + if service_exists: - prompt = ("Do you want to replace the server's systemd " + prompt = ("Do you want to replace the server's webserver " "configuration?") else: - prompt = 'Do you want to add the server to systemd?' + prompt = 'Do you want to add the server to autostart?' do_service = maybe_get_bool(prompt, default, args.yes) if do_service: - with NamedTemporaryFile('w+') as temp_service_file: - temp_service_file.write(dedent('''\ + # do_service needs to complete the following: + # 1) ask to disable the default site, as penguindome might be on + # port 80 + # 2) create the nginx site using the given port / ssl values + # 3) create a service to make sure the socket file exists in tmp + # that the nginx service uses + # 4) create a service to run our wsgi server (gunicorn) + + # 1 - disable default site, nginx is reloaded below, + # so dont worry about reloading it here! + rm_default_nginx_site = maybe_get_bool( + 'Do you want to remove the default NGINX site? you should ' + + 'do this if you are using penguindome on port 80', + default, args.yes) + if rm_default_nginx_site: + try: + subprocess.check_output( + ('rm', + '/etc/nginx/sites-enabled/default'), + stderr=subprocess.STDOUT) + except Exception: + print("ERROR when removing nginx default site " + + "(at /etc/nginx/sites-enabled/default). manually " + + "remove after the installation is complete and reload " + + "nginx!") + + # 2 - create our nginx reverse proxy to our app + if get_server_setting('ssl:enabled'): + ssl_port = get_server_setting('port') + nginx_listen_string = str(ssl_port) + " ssl;" + nginx_ssl_params = ''' + \tssl_certificate = {crt}; + \tssl_certificate_key = {key}; + '''.format( + crt=get_server_setting('ssl:certificate'), + key=get_server_setting('ssl:key') + ) + else: + plaintext_port = get_server_setting('port') + nginx_listen_string = str(plaintext_port) + ';' + nginx_ssl_params = "" + + with NamedTemporaryFile('w+') as temp_nginx_site_file: + temp_nginx_site_file.write(dedent('''\ + server {{ + \tlisten {listen_str} + {ssl_params} + \tclient_max_body_size 2G; + \tkeepalive_timeout 60; + + \tlocation / {{ + \t\tinclude uwsgi_params; + \t\tproxy_pass http://unix:/tmp/penguindome.sock; + \t}} + }} + + server {{ + \tlisten 127.0.0.1:{local_port}; + {ssl_params} + \tclient_max_body_size 2G; + \tkeepalive_timeout 60; + + \tlocation / {{ + \t\tinclude uwsgi_params; + \t\tproxy_pass http://unix:/tmp/penguindome.sock; + \t}} + }} + '''.format( + listen_str=nginx_listen_string, + ssl_params=nginx_ssl_params, + local_port=get_server_setting('local_port')))) + + temp_nginx_site_file.flush() + os.chmod(temp_nginx_site_file.name, 0o644) + shutil.copy(temp_nginx_site_file.name, nginx_site_file) + + # 2 - create a systemd service to make sure our gunicorn socket file + # always exists before gunicorn starts + with NamedTemporaryFile('w+') as temp_gunicorn_sock_service_file: + temp_gunicorn_sock_service_file.write(dedent('''\ + [Unit] + Description=gunicorn socket for penguindome server + + [Socket] + ListenStream=/tmp/penguindome.sock + SocketUser={Username} + SocketGroup=www-data + SocketMode=660 + + [Install] + WantedBy=sockets.target + '''.format(Username=penguinDomeUserName))) + temp_gunicorn_sock_service_file.flush() + os.chmod(temp_gunicorn_sock_service_file.name, 0o644) + os.chown(temp_gunicorn_sock_service_file.name, 0, 0) + shutil.copy( + temp_gunicorn_sock_service_file.name, + "/etc/systemd/system/penguindome_gunicorn.socket" + ) + + # 3 - create a systemd service to run gunicorn + with NamedTemporaryFile('w+') as temp_gunicorn_server_service_file: + temp_gunicorn_server_service_file.write(dedent('''\ [Unit] - Description=PenguinDome Server + Description=penguindome gunicorn daemon + Requires=penguindome_gunicorn.socket After=network.target [Service] - Type=simple - ExecStart={server_exe} + Type=notify + # the specific user that our service will run as + User={Username} + Group={Username} + RuntimeDirectory=gunicorn + WorkingDirectory={server_app_folder} + Environment="PATH={server_venv}/bin" + ExecStart={server_venv}/bin/gunicorn --workers 1 --bind unix:/tmp/penguindome.sock server:app + ExecReload=/bin/kill -s HUP $MAINPID + KillMode=mixed + TimeoutStopSec=5 + PrivateTmp=true [Install] WantedBy=multi-user.target - '''.format(server_exe=os.path.join(top_dir, 'bin', 'server')))) - temp_service_file.flush() - os.chmod(temp_service_file.name, 0o644) - shutil.copy(temp_service_file.name, service_file) - subprocess.check_output(('systemctl', 'daemon-reload'), - stderr=subprocess.STDOUT) + ''').format( + server_venv=top_dir + "/var/server-venv", + server_app_folder=top_dir + "/server", + Username=penguinDomeUserName + )) + temp_gunicorn_server_service_file.flush() + os.chmod(temp_gunicorn_server_service_file.name, 0o644) + os.chown(temp_gunicorn_server_service_file.name, 0, 0) + shutil.copy( + temp_gunicorn_server_service_file.name, + "/etc/systemd/system/penguindome_gunicorn.service" + ) + service_exists = True if service_exists: + # do a reload since we made new service files! try: subprocess.check_output( - ('systemctl', 'is-enabled', 'penguindome-server'), - stderr=subprocess.STDOUT) + ('systemctl', + 'daemon-reload'), stderr=subprocess.STDOUT) + except Exception: + pass + + try: + subprocess.check_output( + ('systemctl', + 'is-enabled', + 'penguindome_gunicorn'), stderr=subprocess.STDOUT) except Exception: - if maybe_get_bool('Do you want to enable the server?', True, - args.yes): + if maybe_get_bool( + 'Do you want to enable the PenguinDome server?', + True, args.yes): subprocess.check_output( - ('systemctl', 'enable', 'penguindome-server'), - stderr=subprocess.STDOUT) + ('systemctl', + 'enable', + 'penguindome_gunicorn'), stderr=subprocess.STDOUT) is_enabled = True - else: - is_enabled = False else: is_enabled = True if is_enabled: try: subprocess.check_output( - ('systemctl', 'status', 'penguindome-server'), - stderr=subprocess.STDOUT) + ('systemctl', + 'status', + 'penguindome_gunicorn'), stderr=subprocess.STDOUT) except Exception: - if maybe_get_bool('Do you want to start the server?', True, - args.yes): + if maybe_get_bool( + 'Do you want to start the PenguinDome server?', + True, args.yes): subprocess.check_output( - ('systemctl', 'start', 'penguindome-server'), - stderr=subprocess.STDOUT) + ('systemctl', + 'start', + 'penguindome_gunicorn'), stderr=subprocess.STDOUT) else: - if maybe_get_bool('Do you want to restart the server?', - server_changed, args.yes): + if maybe_get_bool( + 'Do you want to restart the PenguinDome server?', + server_changed, args.yes): subprocess.check_output( - ('systemctl', 'restart', 'penguindome-server'), - stderr=subprocess.STDOUT) + ('systemctl', + 'restart', + 'penguindome_gunicorn'), stderr=subprocess.STDOUT) + + if maybe_get_bool( + 'Do you want to reload nginx?', server_changed, args.yes): + subprocess.check_output( + ('systemctl', + 'reload', + 'nginx'), stderr=subprocess.STDOUT) if get_server_setting('audit_cron:enabled'): cron_file = '/etc/cron.d/penguindome-audit' @@ -419,6 +677,30 @@ def main(args): print('Installed {}'.format(cron_file)) + # Check the file permissions + fixFilePermissionsDefault = False + if not( + (pwd.getpwuid(os.stat(__file__).st_uid).pw_name == + penguinDomeUserName) and + (pwd.getpwuid(os.stat(__file__).st_gid).pw_name == + currentUserName)): + fixFilePermissionsDefault = True + + fixFiles = maybe_get_bool('Do you want to change fix the file ' + + 'ownership on your PenguinDome install?', + fixFilePermissionsDefault, args.yes) + if fixFiles: + try: + subprocess.check_output( + ('chown', + '-R', + penguinDomeUserName + ":" + currentUserName, + top_dir), + stderr=subprocess.STDOUT + ) + except Exception: + print("Error setting ownership of files!") + if client_changed or not glob.glob(os.path.join(releases_dir, '*.tar.asc')): if client_changed: diff --git a/server/requirements.txt b/server/requirements.txt index 1decbcc..d3d61dd 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -21,3 +21,6 @@ pytz==2018.9 PyYAML==5.3.1 requests==2.21.0 stopit==1.1.2 +gunicorn==20.0.4 +redis==3.3.11 +redis-collections==0.7.0 diff --git a/server/server-setup.sh b/server/server-setup.sh index e7e75c1..50d0859 100755 --- a/server/server-setup.sh +++ b/server/server-setup.sh @@ -19,10 +19,15 @@ venv=var/server-venv cd "$(dirname $0)/.." -# For the time being, this script needs to be run as root because -# initialize.py installs files that only root can install, but -# eventually we may want to fix that, so I've attempted to write this -# build script so that it can run as root or non-root. +# This script needs root to install, but will install penguindome to install under a service account, +# and setting all the administrative files (like building releases and doing audits) will be owned by the +# user calling this script. Therefor it is important that the user calls this script with sudo and not just as +# the root user. We do the check below to tell the user to run as sudo. +if [[ -z "${SUDO_USER}" ]]; then + echo "This script should be called with sudo from the account you wish to administer penguindome with." + exit 1 +fi + arch_build() { git_url="$1"; shift mkdir -p var/makepkg @@ -62,6 +67,7 @@ mkdir -p var if [ "$ID_LIKE" = "debian" ]; then # Ubuntu setup will probably work on Debian, though not tested. + apt-get update apt-get -qq install $(sed 's/#.*//' server/ubuntu-packages.txt) elif [ "$ID_LIKE" = "archlinux" ]; then if ! pacman -S --needed --noconfirm $(sed -e 's/#.*//' -e '/\.git$/d' \ diff --git a/server/server.py b/server/server.py index 4a8e92f..9524343 100644 --- a/server/server.py +++ b/server/server.py @@ -14,25 +14,23 @@ from base64 import b64encode, b64decode from bson import ObjectId -from collections import defaultdict import datetime from flask import Flask, request, Response, abort from functools import wraps from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network -import logging # Only so we can replace werkzeug's logger -from multiprocessing import Process, Manager, RLock +from multiprocessing import Process, RLock +import threading import os from passlib.hash import pbkdf2_sha256 from pymongo import ASCENDING from pymongo.operations import IndexModel -import re import signal -import socket import sys import tempfile import time +import redis +import redis_collections from uuid import uuid4 -import werkzeug._internal # To replace its logger to nix "werkzeug" in logs from penguindome import ( top_dir, @@ -53,36 +51,14 @@ audit_trail_write, ) -# Monkey-patch TCPServer before it's loaded by Werkzeug, to set keepalive on -# its sockets, so that clients on flaky internet connections won't be able to -# wedge Werkzeug child processes forever. - -import werkzeug.serving # To create subclass that sets SO_KEEPALIVE - -old_tcpserver = werkzeug.serving.ForkingWSGIServer - - -class MyTCPServer(old_tcpserver): - def server_bind(self): - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - super(old_tcpserver, self).server_bind() - - -werkzeug.serving.ForkingWSGIServer = MyTCPServer - -# End monkey-patching TCPServer. - -from werkzeug.serving import ( # noqa - WSGIRequestHandler, # So we can enable keep-alive and set timeout - run_simple, -) log = None os.chdir(top_dir) set_gpg('server') -pipes = None -encryptors = None +redis_pipes_db = redis.Redis(host='127.0.0.1', port=6379, db=0) +redis_encryptors_db = redis.Redis(host='127.0.0.1', port=6379, db=1) + pipes_lock = RLock() app = Flask(__name__) @@ -611,18 +587,25 @@ def pipe_create(): key = data['encryption_key'] iv = data['encryption_iv'] uuid = uuid4().hex - encryptors[uuid]['server'] = {'send': Encryptor(key, iv), - 'receive': Encryptor(key, iv)} - pipes[uuid] = { - 'client_opened': False, - 'client_closed': False, - 'server_closed': False, - 'client_to_server': b'', - 'server_to_client': b'', - 'created': time.time(), - 'activity': None, - 'client_hostname': client_hostname, - } + with redis_collections.SyncableDefaultDict( + dict, + redis=redis_encryptors_db, + key='encryptors') as encryptors: + encryptors[uuid]['server'] = {'send': {'k': key, 'i': iv}, + 'receive': {'k': key, 'i': iv}} + with redis_collections.SyncableDict( + redis=redis_pipes_db, + key="pipes") as pipes: + pipes[uuid] = { + 'client_opened': False, + 'client_closed': False, + 'server_closed': False, + 'client_to_server': b'', + 'server_to_client': b'', + 'created': time.time(), + 'activity': None, + 'client_hostname': client_hostname, + } log.debug('Created pipe {}', uuid) return json.dumps({'pipe_id': uuid}) @@ -633,14 +616,21 @@ def pipe_create(): def pipe_open(): data = json.loads(request.form['data']) uuid = data['pipe_id'] - with pipes_lock: + with redis_collections.SyncableDict( + redis=redis_pipes_db, + key="pipes") as pipes: if uuid not in pipes: log.error('Attempt to open nonexistent pipe {}', uuid) abort(404) key = data['encryption_key'] iv = data['encryption_iv'] - encryptors[uuid]['client'] = {'send': Encryptor(key, iv), - 'receive': Encryptor(key, iv)} + + with redis_collections.SyncableDefaultDict( + dict, + redis=redis_encryptors_db, + key='encryptors') as encryptors: + encryptors[uuid]['client'] = {'send': {'k': key, 'i': iv}, + 'receive': {'k': key, 'i': iv}} try: pipe = pipes[uuid] if pipe['client_opened']: @@ -653,6 +643,9 @@ def pipe_open(): return json.dumps({'status': 'ok'}) +# I didnt like the idea of logging, even in one direction, +# ie. what if someone cat's a private key or something? +''' class PipeLogger(object): pending = {} directions = {'send': '<<<', 'receive': '>>>'} @@ -676,7 +669,10 @@ def get(cls, uuid, direction, data): 'masking': 0, 'prefix': p} for d, p in cls.directions.items()} - cls.pending[uuid]['hostname'] = pipes[uuid]['client_hostname'] + with redis_collections.SyncableDict( + redis=redis_pipes_db, + key="pipes") as pipes: + cls.pending[uuid]['hostname'] = pipes[uuid]['client_hostname'] cls.pending[uuid][direction]['data'] += data return cls.pending[uuid] @@ -745,6 +741,7 @@ def finish(cls, uuid): if 'lines' in pending[direction]: cls.emit_lines(pending, direction) del cls.pending[uuid] +''' @app.route('/PenguinDome/v1/server_pipe//send', methods=('POST',)) @@ -755,10 +752,13 @@ def pipe_send(peer_type): raise Exception('Invalid peer type "{}"'.format(peer_type)) data = json.loads(request.form['data']) uuid = data['pipe_id'] - if uuid not in pipes: - log.error('Attempt to send to nonexistent pipe {}', uuid) - abort(404) - with pipes_lock: + with redis_collections.SyncableDict( + redis=redis_pipes_db, + key="pipes") as pipes: + if uuid not in pipes: + log.error('Attempt to send to nonexistent pipe {}', uuid) + abort(404) + pipe = pipes[uuid] pipe['activity'] = time.time() try: @@ -769,11 +769,20 @@ def pipe_send(peer_type): data_field = peer_type + '_to_' + other_peer_type encoded_data = data['data'] encrypted_data = b64decode(encoded_data) - encryptor = encryptors[uuid][peer_type]['send'] - decrypted_data = encryptor.decrypt(encrypted_data) + + with redis_collections.SyncableDefaultDict( + dict, + redis=redis_encryptors_db, + key='encryptors') as encryptors: + encryptor = encryptors[uuid][peer_type]['send'] + decrypted_data = Encryptor( + encryptor['k'], + encryptor['i'] + ).decrypt(encrypted_data) pipe[data_field] += decrypted_data if peer_type == 'server': - PipeLogger.log(uuid, 'send', decrypted_data) + # PipeLogger.log(uuid, 'send', decrypted_data) + pass return json.dumps({'status': 'ok'}) finally: # DictProxy doesn't detect updates to nested dicts. @@ -790,22 +799,31 @@ def pipe_receive(peer_type): raise Exception('Invalid peer type "{}"'.format(peer_type)) data = json.loads(request.form['data']) uuid = data['pipe_id'] - if uuid not in pipes: - log.error('Attempt to receive from nonexistent pipe {}', uuid) - abort(404) - with pipes_lock: + with redis_collections.SyncableDict(redis=redis_pipes_db, + key="pipes") as pipes: + if uuid not in pipes: + log.error('Attempt to receive from nonexistent pipe {}', uuid) + abort(404) + pipe = pipes[uuid] pipe['activity'] = time.time() try: other_peer_type = 'server' if peer_type == 'client' else 'client' data_field = other_peer_type + '_to_' + peer_type if pipe[data_field]: - encryptor = encryptors[uuid][peer_type]['receive'] - encrypted_data = encryptor.encrypt(pipe[data_field]) + with redis_collections.SyncableDefaultDict( + dict, redis=redis_encryptors_db, + key='encryptors') as encryptors: + encryptor = encryptors[uuid][peer_type]['receive'] + encrypted_data = Encryptor( + encryptor['k'], + encryptor['i'] + ).encrypt(pipe[data_field]) encoded_data = b64encode(encrypted_data).decode('utf8') ret = json.dumps({'data': encoded_data}) if peer_type == 'server': - PipeLogger.log(uuid, 'receive', pipe[data_field]) + # PipeLogger.log(uuid, 'receive', pipe[data_field]) + pass pipe[data_field] = b'' return ret closed_field = other_peer_type + '_closed' @@ -825,10 +843,12 @@ def pipe_close(peer_type): raise Exception('Invalid peer type "{}"'.format(peer_type)) data = json.loads(request.form['data']) uuid = data['pipe_id'] - if uuid not in pipes: - log.error('Attempt to close nonexistent pipe {}', uuid) - abort(404) - with pipes_lock: + with redis_collections.SyncableDict( + redis=redis_pipes_db, key="pipes") as pipes: + if uuid not in pipes: + log.error('Attempt to close nonexistent pipe {}', uuid) + abort(404) + pipe = pipes[uuid] try: other_peer_type = 'server' if peer_type == 'client' else 'client' @@ -838,9 +858,14 @@ def pipe_close(peer_type): client_opened = peer_type == 'client' or pipe['client_opened'] if not client_opened or pipe[other_closed_field]: del pipes[uuid] - encryptors.pop(uuid, None) - if peer_type == 'server': - PipeLogger.finish(uuid) + with redis_collections.SyncableDefaultDict( + dict, + redis=redis_encryptors_db, + key='encryptors') as encryptors: + encryptors.pop(uuid, None) + if peer_type == 'server': + # PipeLogger.finish(uuid) + pass return json.dumps({'status': 'ok'}) finally: # DictProxy doesn't detect updates to nested dicts. @@ -849,67 +874,68 @@ def pipe_close(peer_type): def clean_up_encryptors(*args): - with pipes_lock: + with redis_collections.SyncableDefaultDict( + dict, redis=redis_encryptors_db, key='encryptors') as encryptors: for uuid in list(encryptors.keys()): - if uuid not in pipes: - del encryptors[uuid] - signal.signal(signal.SIGALRM, clean_up_encryptors) - signal.alarm(60 * 60) - + with redis_collections.SyncableDict(redis=redis_pipes_db, + key="pipes") as pipes: + if uuid not in pipes: + del encryptors[uuid] + newThread = threading.Timer(60 * 60, clean_up_encryptors) + newThread.daemon = True + newThread.start() -def startServer(port, pipes_arg, local_only=False): - global log, pipes, encryptors - # To enable keep-alive - WSGIRequestHandler.protocol_version = 'HTTP/1.1' - # To prevent DoS attacks by opening connections and not closing them and - # consuming resources and filling all our connection slots in the kernel. - WSGIRequestHandler.timeout = 10 +def startDebugServer(pipes_arg, local_only=False): + global log - log = get_logger('server') - pipes = pipes_arg - encryptors = defaultdict(dict) - clean_up_encryptors() - - werkzeug._internal._logger = logging.getLogger(log.name) + # Get the ports to listen on + port = get_server_setting('port') + local_port = get_server_setting('local_port') - if not local_only: - app.config['deprecated_port'] = get_port_setting( - port, 'deprecated', False) + # TODO: Not too sure what to do with this... i think dict logger + # would probably be the most configurable? # Logbook will handle all logging, via the root handler installed by # `get_logger` when it alls `logbook.compat.redirect_logging()`. del app.logger.handlers[:] app.logger.propagate = True - # Werkzeug has a bug which causes sockets to get stuck in TCP CLOSE_WAIT - # state. Eventually, there are so many stuck sockets that the kernel stops - # allowing new connections to the port, and then attempts to connect to the - # port by clients hang. The "real" way to fix this is to run the server - # under a different WSGI framework, but all of them are more of a pain to - # set up than werkzeug, so I'm hoping that we can solve this problem just - # by handling each request in a separate process so the socket for each - # request will get closed properly when its process exits. - # More info: https://stackoverflow.com/questions/31403261/\ - # flask-werkzeug-sockets-stuck-in-close-wait - kwargs = {'processes': 10} + children = {} + + def sigint_handler(*args): + for p in children.values(): + try: + os.kill(p.pid, signal.SIGKILL) + except Exception: + pass + if local_only: - host = '127.0.0.1' + p = Process(target=app.run, args=("127.0.0.1", local_port)) + p.start() + children[local_port] = p else: - host = '0.0.0.0' + p = Process(target=app.run, args=("0.0.0.0", port)) + p.start() + children[port] = p - ssl_certificate = get_port_setting(port, 'ssl:certificate', None) - ssl_key = get_port_setting(port, 'ssl:key', None) - ssl_enabled = get_port_setting(port, 'ssl:enabled', - bool(ssl_certificate)) - if bool(ssl_certificate) + bool(ssl_key) == 1: - raise Exception( - 'You must specify both certificate and key for SSL!') + # check for errors + time.sleep(1) + problems = False + for port in children.keys(): + if not children[port].is_alive(): + log.error('Child process for port {} died on startup. Maybe ' + 'its port is in use?', port) + problems = True + if problems: + sigint_handler() + log.error('Exiting because one or more servers failed to start up') + sys.exit(1) - if ssl_enabled: - kwargs['ssl_context'] = (ssl_certificate, ssl_key) + signal.signal(signal.SIGINT, sigint_handler) - run_simple(host, port, app, **kwargs) + for p in children.values(): + p.join() def prepare_database(): @@ -943,71 +969,34 @@ def prepare_database(): def clean_up_pipes(*args): now = time.time() - with pipes_lock: + with redis_collections.SyncableDict( + redis=redis_pipes_db, key="pipes") as pipes: for uuid in list(pipes.keys()): active = pipes[uuid]['activity'] or pipes[uuid]['created'] if now - active > 60 * 60: # 1 hour del pipes[uuid] - signal.signal(signal.SIGALRM, clean_up_pipes) - signal.alarm(60 * 60) + newThread = threading.Timer(60 * 60, clean_up_pipes) + newThread.daemon = True + newThread.start() -def main(): - global log, pipes +@app.before_first_request +def serverInit(): + # Keep this for when we run locally for debugging. + # The server startup will be done through nginx+gunicorn + global log log = get_logger('server') - ports = None - port = get_server_setting('port') - if isinstance(port, int): - ports = [port] - elif isinstance(port, dict): - ports = list(port.keys()) - local_port = get_server_setting('local_port') - if local_port in ports: - sys.exit('Configuration error! Local port {} is also configured as a ' - 'non-local port.'.format(local_port)) - ports.append(local_port) + app.config['deprecated_port'] = get_port_setting( + get_server_setting('port'), 'deprecated', False + ) prepare_database() + clean_up_encryptors() - children = {} - - def sigint_handler(*args): - for p in children.values(): - try: - os.kill(p.pid, signal.SIGINT) - except Exception: - pass - - with Manager() as manager: - pipes = manager.dict() - clean_up_pipes() - for port in ports: - p = Process(target=startServer, args=(port, pipes), - kwargs={'local_only': port == local_port}) - p.daemon = True - p.start() - children[port] = p - - # Make sure the children didn't die on startup, e.g., because they - # couldn't bind to their ports. - time.sleep(1) - problems = False - for port in children.keys(): - if not children[port].is_alive(): - log.error('Child process for port {} died on startup. Maybe ' - 'its port is in use?', port) - problems = True - if problems: - sigint_handler() - log.error('Exiting because one or more servers failed to start up') - sys.exit(1) - - signal.signal(signal.SIGINT, sigint_handler) - for p in children.values(): - p.join() + clean_up_pipes() if __name__ == '__main__': - main() + startDebugServer(serverInit(), local_only=True) diff --git a/server/ubuntu-packages.txt b/server/ubuntu-packages.txt index 43bebd7..f9dbaa6 100644 --- a/server/ubuntu-packages.txt +++ b/server/ubuntu-packages.txt @@ -15,7 +15,10 @@ cron gnupg libgfshare-bin openssl +python3-virtualenv python3 systemd tar -virtualenv +haveged +nginx +redis-server diff --git a/tox.ini b/tox.ini index 63269dc..a545e1d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [flake8] -ignore = W504 +ignore = W504,E501 exclude = .git,var,__pycache__,.tox [pytest]