From 4028b0f72268ff87da4f878720a760353df3b5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 28 May 2019 22:42:36 +0200 Subject: [PATCH 1/2] Configure Travis CI. --- .travis.yml | 20 ++++++++++++++++++++ README.md | 1 + requirements-dev.txt | 2 ++ 3 files changed, 23 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7e841b3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: python +cache: pip + +python: + - "2.7" + #- "3.3" + #- "3.4" + #- "3.5" + #- "3.6" + #- "3.7" + +install: + - pip install . + - pip install -r requirements-dev.txt + +script: + # TODO: uncomment isort and flake8 when it will start passing + # - isort --check-only + # - flake8 --max-line-length=120 + - pytest diff --git a/README.md b/README.md index 3fe9727..5380c61 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Multivisor +[![Build Status](https://travis-ci.org/guy881/multivisor.svg?branch=develop)](https://travis-ci.org/guy881/multivisor) A centralized supervisor UI (Web & CLI) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0d5377d..82ef35c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ -r requirements.txt pytest==4.4.1 requests==2.21.0 +isort==4.3.20 +flake8==3.7.7 From 7774aedfc0a83c833edf7baf844aa8df8cab2332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sat, 17 Aug 2019 14:23:55 +0200 Subject: [PATCH 2/2] Implement zerorpc authentication middleware to provide security at the communication level. --- README.md | 57 ++++++++++++++++--- examples/full_example/multivisor.conf | 2 + examples/full_example/supervisord_lid001.conf | 1 + examples/full_example/supervisord_lid002.conf | 1 + generate_secret_key.py | 4 ++ multivisor/multivisor.py | 31 ++++++++-- multivisor/rpc.py | 51 ++++++++++++++--- multivisor/util.py | 30 ++++------ 8 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 generate_secret_key.py diff --git a/README.md b/README.md index 6dc4ce8..3da99d7 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,18 @@ Make sure multivisor is installed in the same environment as the one supervisor( Then, configure the multivisor rpc interface by adding the following lines to your supervisord.conf: -``` +```ini [rpcinterface:multivisor] supervisor.rpcinterface_factory = multivisor.rpc:make_rpc_interface bind=*:9002 +multivisor_keys= ``` - -If no *bind* is given, it defaults to `*:9002`. +Parameters: +* `bind` - address and port on which multivisor rpc interface will listen to multivisor server connections, +it defaults to `*:9002`. +* `multivisor_keys` - comma-separated list of secret keys (much like `authorized_keys` in SSH) which will restrict +access to multivisor servers which have this key provided in supervisor section config. More about this in +security section. Repeat the above procedure for every supervisor you have running. @@ -111,7 +116,7 @@ is `:9002`. Here is some basic example: -``` +```ini [global] name=ACME @@ -128,6 +133,11 @@ url=bugsbunny.acme.org [supervisor:daffyduck] url=daffyduck.acme.org:9007 + +[supervisor:secured] +url=:9002 +# same as multivisor secret key provided in rpc_interface config +multivisor_key= ``` Of course the multivisor itself can be configured in supervisor as a normal @@ -136,7 +146,7 @@ program. ### Authentication To protect multivisor from unwanted access, you can enable required authentication. Specify `username` and `password` parameters in `global` section of your configuration file e.g.: -```bash +```ini [global] username=test password=test @@ -146,11 +156,13 @@ You can also specify `password` as SHA-1 hash in hex, with `{SHA}` prefix: e.g. In order to use authentication, you also need to set `MULTIVISOR_SECRET_KEY` environmental variable, as flask sessions module needs some secret value to create secure session. -You can generate some random hash easily using python: -`python -c 'import os; import binascii; print(binascii.hexlify(os.urandom(32)))'` +You can generate some random hash easily using included script: `generate_secret_key.py`. Remember to restart the server after changes in configuration file. +**Warning**: this authentication on its own doesn't protect your supervisors rpc interface +from other multivisor connecting to it, read more about this in the security section. + ## Build & Install ```bash @@ -195,3 +207,34 @@ npm run dev That's it. If you modify `App.vue` for example, you should see the changes directly on your browser. + + +## Security +The project uses [zerorpc library](https://github.com/0rpc/zerorpc-python/) to invoke commands on supervisors instances. +That's why it is required to specify custom rpc interface in supervisor config. However, zerorpc should be used only in +private networks, as it doesn't come with any kind of authentication. So you can add a supervisor to your multivisor +instance if you know IP and port of exposed multivisor RPC interface and control processes on another machine. That +can be a security issue if those ports are open on the server. + +To address this issue, the project uses a special header with the signature of the send message, generated using +security key provided in both supervisor and multivisor config. The secret key can be generated using the script +`generate_secret_key`. + +See example configuration below. + +**supervisor**: +```ini +[rpcinterface:multivisor] +supervisor.rpcinterface_factory = multivisor.rpc:make_rpc_interface +multivisor_keys=, +``` + +**multivisor**: +```ini +[supervisor:lid001] +url=localhost:9012 +multivisor_key= +``` + +In above example only multivisors which provided `multivisor_key` as `` or `` can +connect to the specified supervisor. \ No newline at end of file diff --git a/examples/full_example/multivisor.conf b/examples/full_example/multivisor.conf index c314280..69b810a 100644 --- a/examples/full_example/multivisor.conf +++ b/examples/full_example/multivisor.conf @@ -1,8 +1,10 @@ [supervisor:lid001] url=localhost:9012 +multivisor_key= [supervisor:lid002] url=localhost:9022 +multivisor_key= [supervisor:baslid001] url=localhost:9032 diff --git a/examples/full_example/supervisord_lid001.conf b/examples/full_example/supervisord_lid001.conf index 824a346..3047f1d 100644 --- a/examples/full_example/supervisord_lid001.conf +++ b/examples/full_example/supervisord_lid001.conf @@ -18,6 +18,7 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [rpcinterface:multivisor] supervisor.rpcinterface_factory = multivisor.rpc:make_rpc_interface bind=*:9012 +multivisor_keys=, [group:Vacuum] programs:vacuum_OH1,vacuum_OH2_1,vacuum_OH2_2 diff --git a/examples/full_example/supervisord_lid002.conf b/examples/full_example/supervisord_lid002.conf index d3d5786..7370ffd 100644 --- a/examples/full_example/supervisord_lid002.conf +++ b/examples/full_example/supervisord_lid002.conf @@ -18,6 +18,7 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [rpcinterface:multivisor] supervisor.rpcinterface_factory = multivisor.rpc:make_rpc_interface bind=*:9022 +multivisor_keys= [group:Vacuum] programs:vacuum_EH diff --git a/generate_secret_key.py b/generate_secret_key.py new file mode 100644 index 0000000..dacc0bd --- /dev/null +++ b/generate_secret_key.py @@ -0,0 +1,4 @@ +import os +import binascii + +print(binascii.hexlify(os.urandom(32))) diff --git a/multivisor/multivisor.py b/multivisor/multivisor.py index 319e07b..0aed1d3 100644 --- a/multivisor/multivisor.py +++ b/multivisor/multivisor.py @@ -13,11 +13,24 @@ from supervisor.xmlrpc import Faults from supervisor.states import RUNNING_STATES -from .util import sanitize_url, filter_patterns +from .util import sanitize_url, filter_patterns, compute_signature log = logging.getLogger('multivisor') +class ClientAuthenticationMiddleware(object): + """ + zerorpc level authentication which adds signature + as event header + """ + def __init__(self, key): + self.key = key + + def client_before_request(self, event): + if 'signature' not in event.header: + event.header['signature'] = compute_signature(event, self.key) + + class Supervisor(dict): Null = { @@ -30,7 +43,7 @@ class Supervisor(dict): 'pid': None } - def __init__(self, name, url): + def __init__(self, name, url, multivisor_key=''): super(Supervisor, self).__init__(self.Null) self.name = self['name'] = name self.url = self['url'] = url @@ -38,7 +51,10 @@ def __init__(self, name, url): addr = sanitize_url(url, protocol='tcp', host=name, port=9002) self.address = addr['url'] self.host = self['host'] = addr['host'] - self.server = zerorpc.Client(self.address) + context = zerorpc.Context() + if multivisor_key: + context.register_middleware(ClientAuthenticationMiddleware(multivisor_key)) + self.server = zerorpc.Client(self.address, context=context) # fill supervisor info before events start coming in self.event_loop = spawn(self.run) @@ -67,7 +83,10 @@ def run(self): except zerorpc.TimeoutExpired: self.log.info('Timeout expired') except Exception as err: - self.log.info('Connection error') + if hasattr(err, 'name') and err.name == 'InvalidSignatureError': + self.log.warning('Invalid authentication details') + else: + self.log.info('Connection error') finally: curr_time = time.time() delta = curr_time - last_retry @@ -320,6 +339,7 @@ def __ne__(self, proc): # Configuration + def load_config(config_file): parser = SafeConfigParser() parser.read(config_file) @@ -335,7 +355,8 @@ def load_config(config_file): name = section[len('supervisor:'):] section_items = dict(parser.items(section)) url = section_items.get('url', '') - supervisors[name] = Supervisor(name, url) + multivisor_key = section_items.get('multivisor_key', '') + supervisors[name] = Supervisor(name, url, multivisor_key=multivisor_key) return config diff --git a/multivisor/rpc.py b/multivisor/rpc.py index 8f4f423..6636673 100644 --- a/multivisor/rpc.py +++ b/multivisor/rpc.py @@ -17,7 +17,8 @@ from gevent import spawn, hub, sleep from gevent.queue import Queue -from zerorpc import stream, Server, LostRemote +from six import text_type +from zerorpc import stream, Server, LostRemote, Context from supervisor.http import NOT_DONE_YET from supervisor.rpcinterface import SupervisorNamespaceRPCInterface @@ -28,8 +29,7 @@ except: unsubscribe = lambda x, y: None -from .util import sanitize_url - +from .util import sanitize_url, compute_signature DEFAULT_BIND = 'tcp://*:9002' @@ -72,7 +72,7 @@ def wrapper(*args, **kwargs): @sync class MultivisorNamespaceRPCInterface(SupervisorNamespaceRPCInterface): - def __init__(self, supervisord, bind): + def __init__(self, supervisord, bind, keys): SupervisorNamespaceRPCInterface.__init__(self, supervisord) self._bind = bind self._channel = queue.Queue() @@ -81,6 +81,19 @@ def __init__(self, supervisord, bind): self._watcher = None self._shutting_down = False self._log = logging.getLogger('MVRPC') + self._keys = self._parse_keys(keys) + + @staticmethod + def _parse_keys(keys): + if keys: + keys = text_type(keys).split(',') + keys = [key.encode() for key in keys] + return keys + return [] + + @property + def authentication_enabled(self): + return len(self._keys) > 0 def _start(self): subscribe(Event, self._handle_event) @@ -178,6 +191,25 @@ def start_rpc_server(multivisor, bind): return future_server.get() +class InvalidSignatureError(Exception): + """ + Occurs when authentication is on, but signature header wasn't send or + was incorrect. + """ + + +class ServerAuthenticationMiddleware(object): + def __init__(self, keys): + self.keys = keys + + def server_before_exec(self, event): + signature = event.header.get(b'signature', None) + allowed_signatures = [compute_signature(event, key) for key in self.keys] + if signature and signature in allowed_signatures: + return + raise InvalidSignatureError + + def run_rpc_server(multivisor, bind, future_server): multivisor._log.info('0RPC: spawn server on {}...'.format(os.getpid())) watcher = hub.get_hub().loop.async() @@ -185,7 +217,12 @@ def run_rpc_server(multivisor, bind, future_server): watcher.start(lambda: spawn(multivisor._dispatch_event)) server = None try: - server = Server(multivisor) + context = Context() + if multivisor.authentication_enabled: + context.register_middleware(ServerAuthenticationMiddleware(multivisor._keys)) + else: + multivisor._log.warning('Authentication not enabled! Please set multivisor_keys config variable') + server = Server(multivisor, context=context) server._stop_event = stop_event server.bind(bind) future_server.put((server, watcher)) @@ -204,13 +241,13 @@ def run_rpc_server(multivisor, bind, future_server): stop_event.set() -def make_rpc_interface(supervisord, bind=DEFAULT_BIND): +def make_rpc_interface(supervisord, bind=DEFAULT_BIND, multivisor_keys=''): # Uncomment following lines to configure python standard logging #log_level = logging.INFO #log_fmt = '%(asctime)-15s %(levelname)s %(threadName)-8s %(name)s: %(message)s' #logging.basicConfig(level=log_level, format=log_fmt) url = sanitize_url(bind, protocol='tcp', host='*', port=9002) - multivisor = MultivisorNamespaceRPCInterface(supervisord, url['url']) + multivisor = MultivisorNamespaceRPCInterface(supervisord, url['url'], keys=multivisor_keys) multivisor._start() return multivisor diff --git a/multivisor/util.py b/multivisor/util.py index f7a8a49..5fa1816 100644 --- a/multivisor/util.py +++ b/multivisor/util.py @@ -1,10 +1,13 @@ import functools import hashlib +import hmac import json import re +from hashlib import sha256 import fnmatch from flask import session, abort +from six import text_type _PROTO_RE_STR = '(?P\w+)\://' _HOST_RE_STR = '?P([\w\-_]+\.)*[\w\-_]+|\*' @@ -39,25 +42,6 @@ def filter_patterns(names, patterns): return result -def load_config(config_file): - parser = SafeConfigParser() - parser.read(config_file) - dft_global = dict(name='multivisor') - - supervisors = {} - config = dict(dft_global, supervisors=supervisors) - config.update(parser.items('global')) - tasks = [] - for section in parser.sections(): - if not section.startswith('supervisor:'): - continue - name = section[len('supervisor:'):] - section_items = dict(parser.items(section)) - url = section_items.get('url', '') - supervisors[name] = Supervisor(name, url) - return config - - def is_login_valid(app, username, password): username = username.strip() password = password.strip() @@ -109,3 +93,11 @@ def wrapper_login_required(*args, **kwargs): return wrapper_login_required return decorator + + +def compute_signature(event, key): + string = event.name + if event.args: + string += ';'.join([text_type(arg) for arg in event.args]) + string = string.encode() + return hmac.new(key, string, sha256).digest()