From 025dd2476fbb3fd0b3aefe338e709bdc3de2ac31 Mon Sep 17 00:00:00 2001 From: Angus Lees Date: Mon, 19 Oct 2015 18:22:16 +1100 Subject: [PATCH] Initial basic privsep functionality Supports starting privileged process via fork or sudo, and a thread-safe client and server communication mechanism. Coming in later changes: - Extend json encoding to deal with non-unicode and more varied dict keys (see test_comm.py). - eventlet test case. - Linux capabilities. Change-Id: If6456631c51d4f2a1c95805ab9d6962b04f172bc Implements: blueprint privsep --- oslo_privsep/comm.py | 201 +++++++++++ oslo_privsep/daemon.py | 457 ++++++++++++++++++++++++ oslo_privsep/priv_context.py | 120 +++++++ oslo_privsep/tests/fixture.py | 53 +++ oslo_privsep/tests/test_comm.py | 107 ++++++ oslo_privsep/tests/test_daemon.py | 61 ++++ oslo_privsep/tests/test_priv_context.py | 115 ++++++ oslo_privsep/tests/test_privsep.py | 28 -- oslo_privsep/tests/testctx.py | 42 +++ requirements.txt | 4 + setup.cfg | 4 + test-requirements.txt | 2 + tox.ini | 6 +- 13 files changed, 1171 insertions(+), 29 deletions(-) create mode 100644 oslo_privsep/comm.py create mode 100644 oslo_privsep/daemon.py create mode 100644 oslo_privsep/priv_context.py create mode 100644 oslo_privsep/tests/fixture.py create mode 100644 oslo_privsep/tests/test_comm.py create mode 100644 oslo_privsep/tests/test_daemon.py create mode 100644 oslo_privsep/tests/test_priv_context.py delete mode 100644 oslo_privsep/tests/test_privsep.py create mode 100644 oslo_privsep/tests/testctx.py diff --git a/oslo_privsep/comm.py b/oslo_privsep/comm.py new file mode 100644 index 0000000..3120a7c --- /dev/null +++ b/oslo_privsep/comm.py @@ -0,0 +1,201 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Serialization/Deserialization for privsep. + +The wire format is a message length encoded as a simple unsigned int +in native byte order (@I in struct.pack-speak), followed by that many +bytes of UTF-8 JSON data. + +""" + +import json +import socket +import struct +import threading + +import six + +from oslo_log import log as logging +from oslo_privsep._i18n import _ + + +LOG = logging.getLogger(__name__) + +_HDRFMT = '@I' +_HDRFMT_LEN = struct.calcsize(_HDRFMT) + + +try: + import greenlet + + def _get_thread_ident(): + # This returns something sensible, even if the current thread + # isn't a greenthread + return id(greenlet.getcurrent()) + +except ImportError: + def _get_thread_ident(): + return threading.current_thread().ident + + +class Serializer(object): + def __init__(self, writesock): + self.writesock = writesock + + def send(self, msg): + buf = json.dumps(msg, ensure_ascii=False).encode('utf-8') + + # json (the library) doesn't support push parsing and JSON + # (the format) doesn't include length information, so we can't + # decode without reading the entire input and blocking. Avoid + # that by explicitly communicating the JSON message length + # first. + self.writesock.sendall(struct.pack(_HDRFMT, len(buf)) + buf) + + def close(self): + # Hilarious. `socket._socketobject.close()` doesn't actually + # call `self._sock.close()`. Oh well, we really wanted a half + # close anyway. + self.writesock.shutdown(socket.SHUT_WR) + + +class Deserializer(six.Iterator): + def __init__(self, readsock): + self.readsock = readsock + + def __iter__(self): + return self + + def _read_n(self, n): + """Read exactly N bytes. Raises EOFError on premature EOF""" + data = [] + while n > 0: + tmp = self.readsock.recv(n) + if not tmp: + raise EOFError(_('Premature EOF during deserialization')) + data.append(tmp) + n -= len(tmp) + return b''.join(data) + + def __next__(self): + try: + buflen, = struct.unpack(_HDRFMT, self._read_n(_HDRFMT_LEN)) + except EOFError: + raise StopIteration + + return json.loads(self._read_n(buflen).decode('utf-8')) + + +class Future(object): + """A very simple object to track the return of a function call""" + + def __init__(self, lock): + self.condvar = threading.Condition(lock) + self.error = None + self.data = None + + def set_result(self, data): + """Must already be holding lock used in constructor""" + self.data = data + self.condvar.notify() + + def set_exception(self, exc): + """Must already be holding lock used in constructor""" + self.error = exc + self.condvar.notify() + + def result(self): + """Must already be holding lock used in constructor""" + self.condvar.wait() + if self.error is not None: + raise self.error + return self.data + + +class ClientChannel(object): + def __init__(self, sock): + self.writer = Serializer(sock) + self.lock = threading.Lock() + self.reader_thread = threading.Thread( + name='privsep_reader', + target=self._reader_main, + args=(Deserializer(sock),), + ) + self.reader_thread.daemon = True + self.outstanding_msgs = {} + + self.reader_thread.start() + + def _reader_main(self, reader): + """This thread owns and demuxes the read channel""" + for msg in reader: + msgid, data = msg + with self.lock: + assert msgid in self.outstanding_msgs + self.outstanding_msgs[msgid].set_result(data) + + # EOF. Perhaps the privileged process exited? + # Send an IOError to any oustanding waiting readers. Assuming + # the write direction is also closed, any new writes should + # get an immediate similar error. + LOG.debug('EOF on privsep read channel') + + exc = IOError(_('Premature eof waiting for privileged process')) + with self.lock: + for mbox in self.outstanding_msgs.values(): + mbox.set_exception(exc) + + def send_recv(self, msg): + myid = _get_thread_ident() + future = Future(self.lock) + + with self.lock: + assert myid not in self.outstanding_msgs + self.outstanding_msgs[myid] = future + try: + self.writer.send((myid, msg)) + + reply = future.result() + finally: + del self.outstanding_msgs[myid] + + return reply + + def close(self): + with self.lock: + self.writer.close() + + self.reader_thread.join() + + +class ServerChannel(six.Iterator): + """Server-side twin to ClientChannel""" + + def __init__(self, sock): + self.rlock = threading.Lock() + self.reader_iter = iter(Deserializer(sock)) + self.wlock = threading.Lock() + self.writer = Serializer(sock) + + def __iter__(self): + return self + + def __next__(self): + with self.rlock: + return next(self.reader_iter) + + def send(self, msg): + with self.wlock: + self.writer.send(msg) diff --git a/oslo_privsep/daemon.py b/oslo_privsep/daemon.py new file mode 100644 index 0000000..d361fbb --- /dev/null +++ b/oslo_privsep/daemon.py @@ -0,0 +1,457 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +'''Privilege separation ("privsep") daemon. + +To ease transition this supports 2 alternative methods of starting the +daemon, all resulting in a helper process running with elevated +privileges and open socket(s) to the original process: + +1. Start via fork() + + Assumes process currently has all required privileges and is about + to drop them (perhaps by setuid to an unprivileged user). If the + the initial environment is secure and `PrivContext.start(Method.FORK)` + is called early in `main()`, then this is the most secure and + simplest. In particular, if the initial process is already running + as non-root (but with sufficient capabilities, via eg suitable + systemd service files), then no part needs to involve uid=0 or + sudo. + +2. Start via sudo/rootwrap + + This starts the privsep helper on first use via sudo and rootwrap, + and communicates via a temporary Unix socket passed on the command + line. The communication channel is briefly exposed in the + filesystem, but is protected with file permissions and connecting + to it only grants access to the unprivileged process. Requires a + suitable entry in sudoers or rootwrap.conf filters. + +The privsep daemon exits when the communication channel is closed, +(which usually occurs when the unprivileged process exits). + +''' + +import enum +import errno +import fcntl +import grp +import io +import logging as pylogging +import os +import pwd +import shlex +import socket +import subprocess +import sys +import tempfile +import threading + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import importutils + +from oslo_privsep import comm +from oslo_privsep._i18n import _, _LE, _LI + + +LOG = logging.getLogger(__name__) + + +@enum.unique +class StdioFd(enum.IntEnum): + # NOTE(gus): We can't use sys.std*.fileno() here. sys.std* + # objects may be random file-like objects that may not match the + # true system std* fds - and indeed may not even have a file + # descriptor at all (eg: test fixtures that monkey patch + # fixtures.StringStream onto sys.stdout). Below we always want + # the _real_ well-known 0,1,2 Unix fds during os.dup2 + # manipulation. + STDIN = 0 + STDOUT = 1 + STDERR = 2 + + +@enum.unique +class Message(enum.IntEnum): + """Types of messages sent across the communication channel""" + PING = 1 + PONG = 2 + CALL = 3 + RET = 4 + ERR = 5 + + +class FailedToDropPrivileges(Exception): + pass + + +class ProtocolError(Exception): + pass + + +def set_cloexec(fd): + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + if (flags & fcntl.FD_CLOEXEC) == 0: + flags |= fcntl.FD_CLOEXEC + fcntl.fcntl(fd, fcntl.F_SETFD, flags) + + +def setuid(user_id_or_name): + try: + new_uid = int(user_id_or_name) + except (TypeError, ValueError): + new_uid = pwd.getpwnam(user_id_or_name).pw_uid + if new_uid != 0: + try: + os.setuid(new_uid) + except OSError: + msg = _('Failed to set uid %s') % new_uid + LOG.critical(msg) + raise FailedToDropPrivileges(msg) + + +def setgid(group_id_or_name): + try: + new_gid = int(group_id_or_name) + except (TypeError, ValueError): + new_gid = grp.getgrnam(group_id_or_name).gr_gid + if new_gid != 0: + try: + os.setgid(new_gid) + except OSError: + msg = _('Failed to set gid %s') % new_gid + LOG.critical(msg) + raise FailedToDropPrivileges(msg) + + +class _ClientChannel(comm.ClientChannel): + """Our protocol, layered on the basic primitives in comm.ClientChannel""" + + def __init__(self, sock): + super(_ClientChannel, self).__init__(sock) + self.exchange_ping() + + def exchange_ping(self): + try: + # exchange "ready" messages + reply = self.send_recv((Message.PING.value,)) + success = reply[0] == Message.PONG + except Exception as e: + LOG.exception( + _LE('Error while sending initial PING to privsep: %s'), e) + success = False + if not success: + msg = _('Privsep daemon failed to start') + LOG.critical(msg) + raise FailedToDropPrivileges(msg) + + def remote_call(self, name, args, kwargs): + result = self.send_recv((Message.CALL.value, name, args, kwargs)) + if result[0] == Message.RET: + # (RET, return value) + return result[1] + elif result[0] == Message.ERR: + # (ERR, exc_type, args) + # + # TODO(gus): see what can be done to preserve traceback + # (without leaking local values) + exc_type = importutils.import_class(result[1]) + raise exc_type(*result[2]) + else: + raise ProtocolError(_('Unexpected response: %r') % result) + + +def _fd_logger(level=logging.WARN): + """Helper that returns a file object that is asynchronously logged""" + read_fd, write_fd = os.pipe() + read_end = io.open(read_fd, 'r', 1) + write_end = io.open(write_fd, 'w', 1) + + def logger(f): + for line in f: + LOG.log(level, 'privsep log: %s', line.rstrip()) + t = threading.Thread( + name='fd_logger', + target=logger, args=(read_end,) + ) + t.daemon = True + t.start() + + return write_end + + +def replace_logging(handler, log_root=None): + if log_root is None: + log_root = logging.getLogger(None).logger # root logger + for h in log_root.handlers: + log_root.removeHandler(h) + log_root.addHandler(handler) + + +class ForkingClientChannel(_ClientChannel): + def __init__(self, context): + """Start privsep daemon using fork() + + Assumes we already have required privileges. + """ + + sock_a, sock_b = socket.socketpair() + + # Python bug workaround. It seems socketpair sockets aren't + # wrapped in the same way as socket.socket return values are. The + # unwrapped socket object in py27 contains a broken .makefile + # implementation (tries to seek). + if not isinstance(sock_a, socket.SocketType): + sock_a = socket.SocketType(_sock=sock_a) + sock_b = socket.SocketType(_sock=sock_b) + + for s in (sock_a, sock_b): + s.setblocking(True) + # Important that these sockets don't get leaked + set_cloexec(s) + + # Try to prevent any buffered output from being written by both + # parent and child. + for f in (sys.stdout, sys.stderr): + f.flush() + + log_fd = _fd_logger() + + if os.fork() == 0: + # child + + # replace root logger early (to capture any errors below) + replace_logging(pylogging.StreamHandler(log_fd)) + + sock_a.close() + Daemon(comm.ServerChannel(sock_b), context=context).run() + LOG.debug('privsep daemon exiting') + os._exit(0) + + # parent + + sock_b.close() + super(ForkingClientChannel, self).__init__(sock_a) + + +class RootwrapClientChannel(_ClientChannel): + def __init__(self, context): + """Start privsep daemon using exec() + + Uses sudo/rootwrap to gain privileges. + """ + + # We need to be able to reconstruct the context object in the new + # python process we'll get after rootwrap/sudo. This means we + # need to construct the context object and store it somewhere + # globally accessible, and then use that python name to find it + # again in the new python interpreter. Yes, it's all a bit + # clumsy, and none of it is required when using the fork-based + # alternative above. + # These asserts here are just attempts to catch errors earlier. + # TODO(gus): Consider replacing with setuptools entry_points. + assert context.pypath is not None, ( + 'RootwrapClientChannel requires priv_context ' + 'pypath to be specified') + assert importutils.import_class(context.pypath) is context, ( + 'RootwrapClientChannel requires priv_context pypath ' + 'for context object') + + listen_sock = socket.socket(socket.AF_UNIX) + + # Note we listen() on the unprivileged side, and connect to it + # from the privileged process. This means there is no exposed + # attack point on the privileged side. + + # NB: Permissions on sockets are not checked on some (BSD) Unices + # so create socket in a private directory for safety. Privsep + # daemon will (initially) be running as root, so will still be + # able to connect to sock path. + tmpdir = tempfile.mkdtemp() # NB: created with 0700 perms + + try: + sockpath = os.path.join(tmpdir, 'privsep.sock') + listen_sock.bind(sockpath) + listen_sock.listen(1) + + cmd = shlex.split(context.conf.helper_command) + [ + '--privsep_context', context.pypath, + '--privsep_sock_path', sockpath] + LOG.info(_LI('Running privsep helper: %s'), cmd) + proc = subprocess.Popen(cmd, shell=False, stderr=_fd_logger()) + if proc.wait() != 0: + msg = (_LE('privsep helper command exited non-zero (%s)') % + proc.returncode) + LOG.critical(msg) + raise FailedToDropPrivileges(msg) + LOG.info(_LI('Spawned new privsep daemon via rootwrap')) + + sock, _addr = listen_sock.accept() + LOG.debug('Accepted privsep connection to %s', sockpath) + + finally: + # Don't need listen_sock anymore, so clean up. + listen_sock.close() + try: + os.unlink(sockpath) + except OSError as e: + if e.errno != errno.ENOENT: + raise + os.rmdir(tmpdir) + + super(RootwrapClientChannel, self).__init__(sock) + + +class Daemon(object): + """NB: This doesn't fork() - do that yourself before calling run()""" + + def __init__(self, channel, context): + self.channel = channel + self.context = context + self.user = context.conf.user + self.group = context.conf.group + + def run(self): + """Run request loop. Sets up environment, then calls loop()""" + os.chdir("/") + os.umask(0) + self._drop_privs() + self._close_stdio() + + self.loop() + + def _close_stdio(self): + with open(os.devnull, 'w+') as devnull: + os.dup2(devnull.fileno(), StdioFd.STDIN) + os.dup2(devnull.fileno(), StdioFd.STDOUT) + # stderr is left untouched + + def _drop_privs(self): + if self.group is not None: + try: + os.setgroups([]) + except OSError: + msg = _('Failed to remove supplemental groups') + LOG.critical(msg) + raise FailedToDropPrivileges(msg) + if self.user is not None: + setuid(self.user) + + LOG.info(_LI('privsep process running with uid/gid: %(uid)s/%(gid)s'), + {'uid': os.getuid(), 'gid': os.getgid()}) + + def _process_cmd(self, cmd, *args): + if cmd == Message.PING: + return (Message.PONG.value,) + + elif cmd == Message.CALL: + name, f_args, f_kwargs = args + func = importutils.import_class(name) + + if not self.context.is_entrypoint(func): + msg = _('Invalid privsep function: %s not exported') % name + raise NameError(msg) + + ret = func(*f_args, **f_kwargs) + return (Message.RET.value, ret) + + raise ProtocolError(_('Unknown privsep cmd: %s') % cmd) + + def loop(self): + """Main body of daemon request loop""" + LOG.info(_LI('privsep daemon running as pid %s'), os.getpid()) + + # We *are* this context now - any calls through it should be + # executed locally. + self.context.set_client_mode(False) + + for msgid, msg in self.channel: + LOG.debug('privsep: request[%(msgid)s]: %(req)s', + {'msgid': msgid, 'req': msg}) + try: + reply = self._process_cmd(*msg) + except Exception as e: + LOG.debug( + 'privsep: Exception during request[%(msgid)s]: %(err)s', + {'msgid': msgid, 'err': e}, exc_info=True) + cls = e.__class__ + cls_name = '%s.%s' % (cls.__module__, cls.__name__) + reply = (Message.ERR.value, cls_name, e.args) + + try: + LOG.debug('privsep: reply[%(msgid)s]: %(reply)s', + {'msgid': msgid, 'reply': reply}) + self.channel.send((msgid, reply)) + except IOError as e: + if e.errno == errno.EPIPE: + # Write stream closed, exit loop + break + raise + + LOG.debug('Socket closed, shutting down privsep daemon') + + +def helper_main(): + """Start privileged process, serving requests over a Unix socket.""" + + cfg.CONF.register_cli_opts([ + cfg.StrOpt('privsep_context', required=True), + cfg.StrOpt('privsep_sock_path', required=True), + ]) + + logging.register_options(cfg.CONF) + + cfg.CONF(args=sys.argv[1:], project='privsep') + logging.setup(cfg.CONF, 'privsep') + + # We always log to stderr. Replace the root logger we just set up. + replace_logging(pylogging.StreamHandler(sys.stderr)) + + LOG.info(_LI('privsep daemon starting')) + + context = importutils.import_class(cfg.CONF.privsep_context) + from oslo_privsep import priv_context # Avoid circular import + if not isinstance(context, priv_context.PrivContext): + LOG.fatal(_LE('--privsep_context must be the (python) name of a ' + 'PrivContext object')) + + sock = socket.socket(socket.AF_UNIX) + sock.connect(cfg.CONF.privsep_sock_path) + set_cloexec(sock) + channel = comm.ServerChannel(sock) + + # Channel is set up, so fork off daemon "in the background" and exit + if os.fork() != 0: + # parent + return + + # child + + # Note we don't move into a new process group/session like a + # regular daemon might, since we _want_ to remain associated with + # the originating (unprivileged) process. + + try: + Daemon(channel, context).run() + except Exception as e: + LOG.exception(e) + sys.exit(str(e)) + + LOG.debug('privsep daemon exiting') + sys.exit(0) + + +if __name__ == '__main__': + helper_main() diff --git a/oslo_privsep/priv_context.py b/oslo_privsep/priv_context.py new file mode 100644 index 0000000..fa2e60f --- /dev/null +++ b/oslo_privsep/priv_context.py @@ -0,0 +1,120 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import enum +import functools + +from oslo_config import cfg +from oslo_log import log as logging + +from oslo_privsep import daemon +from oslo_privsep._i18n import _, _LW + + +LOG = logging.getLogger(__name__) + +OPTS = [ + cfg.StrOpt('user', + help=_('User that the privsep daemon should run as.')), + cfg.StrOpt('group', + help=_('Group that the privsep daemon should run as.')), + cfg.StrOpt('helper_command', + default=('sudo privsep-helper' + # TODO(gus): how do I find a good config path? + ' --config-file=/etc/$project/$project.conf'), + help=_('Command to invoke via sudo/rootwrap to start ' + 'the privsep daemon.')), +] + +_ENTRYPOINT_ATTR = 'privsep_entrypoint' + + +@enum.unique +class Method(enum.Enum): + FORK = 1 + ROOTWRAP = 2 + + +class PrivContext(object): + def __init__(self, prefix, cfg_section='privsep', pypath=None): + self.pypath = pypath + self.prefix = prefix + self.cfg_section = cfg_section + self.client_mode = True + self.channel = None + + cfg.CONF.register_opts(OPTS, group=cfg_section) + + @property + def conf(self): + """Return the oslo.config section object as lazily as possible.""" + # Need to avoid looking this up before oslo_config has been + # properly initialized. + return cfg.CONF[self.cfg_section] + + def __repr__(self): + return 'PrivContext(cfg_section=%s)' % self.cfg_section + + def set_client_mode(self, enabled): + self.client_mode = enabled + + def entrypoint(self, func): + """This is intended to be used as a decorator.""" + + assert func.__module__.startswith(self.prefix), ( + '%r entrypoints must be below "%s"' % (self, self.prefix)) + + # Right now, we only track a single context in + # _ENTRYPOINT_ATTR. This could easily be expanded into a set, + # but that will increase the memory overhead. Revisit if/when + # someone has a need to associate the same entrypoint with + # multiple contexts. + assert getattr(func, _ENTRYPOINT_ATTR, None) is None, ( + '%r is already associated with another PrivContext' % func) + + f = functools.partial(self._wrap, func) + setattr(f, _ENTRYPOINT_ATTR, self) + return f + + def is_entrypoint(self, func): + return getattr(func, _ENTRYPOINT_ATTR, None) is self + + def _wrap(self, func, *args, **kwargs): + if self.client_mode: + name = '%s.%s' % (func.__module__, func.__name__) + if self.channel is None: + self.start() + return self.channel.remote_call(name, args, kwargs) + else: + return func(*args, **kwargs) + + def start(self, method=Method.ROOTWRAP): + if self.channel is not None: + LOG.warn(_LW('privsep daemon already running')) + return + + if method is Method.ROOTWRAP: + channel = daemon.RootwrapClientChannel(context=self) + elif method is Method.FORK: + channel = daemon.ForkingClientChannel(context=self) + else: + raise ValueError('Unknown method: %s' % method) + + self.channel = channel + + def stop(self): + if self.channel is not None: + self.channel.close() + self.channel = None diff --git a/oslo_privsep/tests/fixture.py b/oslo_privsep/tests/fixture.py new file mode 100644 index 0000000..3cda563 --- /dev/null +++ b/oslo_privsep/tests/fixture.py @@ -0,0 +1,53 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import fixtures +import os +import sys + +from oslo_config import fixture as cfg_fixture +from oslo_log import log as logging + +from oslo_privsep import priv_context + +LOG = logging.getLogger(__name__) + + +class UnprivilegedPrivsepFixture(fixtures.Fixture): + def __init__(self, context): + self.context = context + + def setUp(self): + super(UnprivilegedPrivsepFixture, self).setUp() + + self.conf = self.useFixture(cfg_fixture.Config()).conf + for k in ('user', 'group'): + self.conf.set_override( + k, None, group=self.context.cfg_section) + + orig_pid = os.getpid() + try: + self.context.start(method=priv_context.Method.FORK) + except Exception as e: + # py3 unittest/testtools/something catches fatal + # exceptions from child processes and tries to treat them + # like regular non-fatal test failures. Here we attempt + # to undo that. + if os.getpid() == orig_pid: + raise + LOG.exception(e) + sys.exit(1) + + self.addCleanup(self.context.stop) diff --git a/oslo_privsep/tests/test_comm.py b/oslo_privsep/tests/test_comm.py new file mode 100644 index 0000000..16fc3cc --- /dev/null +++ b/oslo_privsep/tests/test_comm.py @@ -0,0 +1,107 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from oslotest import base + +from oslo_privsep import comm + + +class BufSock(object): + def __init__(self): + self.readpos = 0 + self.buf = six.BytesIO() + + def recv(self, bufsize): + if self.buf.closed: + return b'' + self.buf.seek(self.readpos, 0) + data = self.buf.read(bufsize) + self.readpos += len(data) + return data + + def sendall(self, data): + self.buf.seek(0, 2) + self.buf.write(data) + + def shutdown(self, _flag): + self.buf.close() + + +class TestSerialization(base.BaseTestCase): + def setUp(self): + super(TestSerialization, self).setUp() + + sock = BufSock() + + self.input = comm.Serializer(sock) + self.output = iter(comm.Deserializer(sock)) + + def send(self, data): + self.input.send(data) + return next(self.output) + + def assertSendable(self, value): + self.assertEqual(value, self.send(value)) + + def test_none(self): + self.assertSendable(None) + + def test_bool(self): + self.assertSendable(True) + self.assertSendable(False) + + def test_int(self): + self.assertSendable(42) + self.assertSendable(-84) + + def test_bytes(self): + # TODO(gus): json needs help to support non-unicode strings + # data = b'\x00\x01\x02\xfd\xfe\xff' + # self.assertSendable(data) + pass + + def test_unicode(self): + data = u'\u4e09\u9df9' + self.assertSendable(data) + + def test_tuple(self): + # NB! currently tuples get converted to lists by serialization. + self.assertEqual([1, 'foo'], self.send((1, 'foo'))) + + def test_list(self): + self.assertSendable([1, 'foo']) + + def test_dict(self): + self.assertSendable( + { + 'a': 'b', + # TODO(gus): json needs help to support non-string keys + # 1: 2, + # None: None, + # (1, 2): (3, 4), + } + ) + + def test_badobj(self): + class UnknownClass(object): + pass + + obj = UnknownClass() + self.assertRaises(TypeError, self.send, obj) + + def test_eof(self): + self.input.close() + self.assertRaises(StopIteration, next, self.output) diff --git a/oslo_privsep/tests/test_daemon.py b/oslo_privsep/tests/test_daemon.py new file mode 100644 index 0000000..e3ecf11 --- /dev/null +++ b/oslo_privsep/tests/test_daemon.py @@ -0,0 +1,61 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fixtures +import time + +from oslo_log import log as logging + +from oslo_privsep.tests import testctx + + +LOG = logging.getLogger(__name__) + + +def undecorated(): + pass + + +@testctx.context.entrypoint +def logme(level, msg): + LOG.log(level, '%s', msg) + + +class LogTest(testctx.TestContextTestCase): + def setUp(self): + super(LogTest, self).setUp() + self.logger = self.useFixture(fixtures.FakeLogger( + name=None, level=logging.INFO)) + + def test_priv_log(self): + logme(logging.DEBUG, 'test@DEBUG') + logme(logging.WARN, 'test@WARN') + + time.sleep(0.1) # Hack to give logging thread a chance to run + + # TODO(gus): Currently severity information is lost and + # everything is logged as INFO. Fixing this probably requires + # writing structured messages to the logging socket. + # + # self.assertNotIn('test@DEBUG', self.logger.output) + + self.assertIn('test@WARN', self.logger.output) + + +class TestDaemon(testctx.TestContextTestCase): + + def test_unexported(self): + self.assertRaisesRegexp( + NameError, 'undecorated not exported', + testctx.context._wrap, undecorated) diff --git a/oslo_privsep/tests/test_priv_context.py b/oslo_privsep/tests/test_priv_context.py new file mode 100644 index 0000000..9a36f30 --- /dev/null +++ b/oslo_privsep/tests/test_priv_context.py @@ -0,0 +1,115 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import os +import pipes +import sys + +from oslo_log import log as logging + +from oslo_privsep import daemon +from oslo_privsep import priv_context +from oslo_privsep.tests import testctx + +LOG = logging.getLogger(__name__) + + +@testctx.context.entrypoint +def priv_getpid(): + return os.getpid() + + +@testctx.context.entrypoint +def add1(arg): + return arg + 1 + + +class CustomError(Exception): + def __init__(self, code, msg): + super(CustomError, self).__init__(code, msg) + self.code = code + self.msg = msg + + def __str__(self): + return 'Code %s: %s' % (self.code, self.msg) + + +@testctx.context.entrypoint +def fail(custom=False): + if custom: + raise CustomError(42, 'omg!') + else: + raise RuntimeError("I can't let you do that Dave") + + +class TestSeparation(testctx.TestContextTestCase): + def test_getpid(self): + # Verify that priv_getpid() was executed in another process. + priv_pid = priv_getpid() + self.assertNotMyPid(priv_pid) + + def test_client_mode(self): + self.assertNotMyPid(priv_getpid()) + + self.addCleanup(testctx.context.set_client_mode, True) + + testctx.context.set_client_mode(False) + # priv_getpid() should now run locally (and return our pid) + self.assertEqual(os.getpid(), priv_getpid()) + + testctx.context.set_client_mode(True) + # priv_getpid() should now run remotely again + self.assertNotMyPid(priv_getpid()) + + +class RootwrapTest(testctx.TestContextTestCase): + def setUp(self): + super(RootwrapTest, self).setUp() + testctx.context.stop() + + # Generate a command that will run daemon.helper_main without + # requiring it to be properly installed. + cmd = [ + 'env', + 'PYTHON_PATH=%s' % os.path.pathsep.join(sys.path), + sys.executable, daemon.__file__, + ] + if LOG.isEnabledFor(logging.DEBUG): + cmd.append('--debug') + + self.privsep_conf.set_override( + 'helper_command', ' '.join(map(pipes.quote, cmd)), + group=testctx.context.cfg_section) + + testctx.context.start(method=priv_context.Method.ROOTWRAP) + + def test_getpid(self): + # Verify that priv_getpid() was executed in another process. + priv_pid = priv_getpid() + self.assertNotMyPid(priv_pid) + + +class TestSerialization(testctx.TestContextTestCase): + def test_basic_functionality(self): + self.assertEqual(43, add1(42)) + + def test_raises_standard(self): + self.assertRaisesRegexp( + RuntimeError, "I can't let you do that Dave", fail) + + def test_raises_custom(self): + exc = self.assertRaises(CustomError, fail, custom=True) + self.assertEqual(exc.code, 42) + self.assertEqual(exc.msg, 'omg!') diff --git a/oslo_privsep/tests/test_privsep.py b/oslo_privsep/tests/test_privsep.py deleted file mode 100644 index 70e1a44..0000000 --- a/oslo_privsep/tests/test_privsep.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- - -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -""" -test_privsep ----------------------------------- - -Tests for `privsep` module. -""" - -from oslotest import base - - -class TestPrivsep(base.BaseTestCase): - - def test_something(self): - pass diff --git a/oslo_privsep/tests/testctx.py b/oslo_privsep/tests/testctx.py new file mode 100644 index 0000000..a3afdd3 --- /dev/null +++ b/oslo_privsep/tests/testctx.py @@ -0,0 +1,42 @@ +# Copyright 2015 Rackspace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + +from oslotest import base + +from oslo_privsep import priv_context +import oslo_privsep.tests +from oslo_privsep.tests import fixture + + +context = priv_context.PrivContext( + # This context allows entrypoints anywhere below oslo_privsep.tests. + oslo_privsep.tests.__name__, + pypath=__name__ + '.context', +) + + +class TestContextTestCase(base.BaseTestCase): + def setUp(self): + super(TestContextTestCase, self).setUp() + privsep_fixture = self.useFixture( + fixture.UnprivilegedPrivsepFixture(context)) + self.privsep_conf = privsep_fixture.conf + + def assertNotMyPid(self, pid): + # Verify that `pid` is some positive integer, that isn't our pid + self.assertIsInstance(pid, int) + self.assertTrue(pid > 0) + self.assertNotEqual(os.getpid(), pid) diff --git a/requirements.txt b/requirements.txt index d6ce4ed..15a353b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,8 @@ # process, which may cause wedges in the gate later. Babel>=1.3 +oslo.log>=1.8.0 # Apache-2.0 oslo.i18n>=1.5.0 # Apache-2.0 +oslo.config>=2.6.0 # Apache-2.0 +oslo.utils>=2.4.0,!=2.6.0 # Apache-2.0 +enum34;python_version=='2.7' or python_version=='2.6' diff --git a/setup.cfg b/setup.cfg index 8813d5c..a34baad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,10 @@ source-dir = doc/source build-dir = doc/build all_files = 1 +[entry_points] +console_scripts = + privsep_helper = oslo_privsep.daemon:helper_main + [upload_sphinx] upload-dir = doc/build/html diff --git a/test-requirements.txt b/test-requirements.txt index fca20c8..2740639 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,6 +4,8 @@ hacking<0.11,>=0.10.2 oslotest>=1.10.0 # Apache-2.0 +mock>=1.2 +fixtures>=1.3.1 # These are needed for docs generation oslosphinx>=2.5.0 # Apache-2.0 diff --git a/tox.ini b/tox.ini index a16cf23..77e1f8e 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,13 @@ envlist = py34,py26,py27,pypy,pep8 # NOTE(dhellmann): We cannot set usedevelop=True # for oslo libraries because of the namespace package. #usedevelop = True -install_command = pip install -U {opts} {packages} +# We require pip>=6 before we can even parse requirements.txt correctly. +install_command = + sh -c 'pip install -U "pip>=6" && pip install -U "$@"' pip {opts} {packages} setenv = VIRTUAL_ENV={envdir} +whitelist_externals = + /bin/sh deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt commands = python setup.py testr --slowest --testr-args='{posargs}'