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
This commit is contained in:
Angus Lees 2015-10-19 18:22:16 +11:00
parent 3f1146730c
commit 025dd2476f
13 changed files with 1171 additions and 29 deletions

201
oslo_privsep/comm.py Normal file

@ -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)

457
oslo_privsep/daemon.py Normal file

@ -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()

@ -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

@ -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)

@ -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)

@ -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)

@ -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!')

@ -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

@ -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)

@ -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'

@ -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

@ -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

@ -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}'