Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication at the zerorpc level #56

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<some_secret_key>
```

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.

Expand All @@ -111,7 +116,7 @@ is `<name>:9002`.

Here is some basic example:

```
```ini
[global]
name=ACME

Expand All @@ -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=<some_secret_key>
```

Of course the multivisor itself can be configured in supervisor as a normal
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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=<some_secret_key>,<another_secret_key>
```

**multivisor**:
```ini
[supervisor:lid001]
url=localhost:9012
multivisor_key=<some_secret_key>
```

In above example only multivisors which provided `multivisor_key` as `<some_secret_key>` or `<another_secret_key>` can
connect to the specified supervisor.
2 changes: 2 additions & 0 deletions examples/full_example/multivisor.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
[supervisor:lid001]
url=localhost:9012
multivisor_key=<some_secret_key>

[supervisor:lid002]
url=localhost:9022
multivisor_key=<some_secret_key>

[supervisor:baslid001]
url=localhost:9032
Expand Down
1 change: 1 addition & 0 deletions examples/full_example/supervisord_lid001.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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=<some_secret_key>,<another_secret_key>

[group:Vacuum]
programs:vacuum_OH1,vacuum_OH2_1,vacuum_OH2_2
Expand Down
1 change: 1 addition & 0 deletions examples/full_example/supervisord_lid002.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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=<some_secret_key>

[group:Vacuum]
programs:vacuum_EH
Expand Down
4 changes: 4 additions & 0 deletions generate_secret_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import os
import binascii

print(binascii.hexlify(os.urandom(32)))
31 changes: 26 additions & 5 deletions multivisor/multivisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -30,15 +43,18 @@ 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
self.log = log.getChild(name)
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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -320,6 +339,7 @@ def __ne__(self, proc):

# Configuration


def load_config(config_file):
parser = SafeConfigParser()
parser.read(config_file)
Expand All @@ -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


Expand Down
51 changes: 44 additions & 7 deletions multivisor/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'

Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -178,14 +191,38 @@ 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()
stop_event = threading.Event()
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))
Expand All @@ -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
30 changes: 11 additions & 19 deletions multivisor/util.py
Original file line number Diff line number Diff line change
@@ -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<protocol>\w+)\://'
_HOST_RE_STR = '?P<host>([\w\-_]+\.)*[\w\-_]+|\*'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()