Change-Id: I58ca56045ff3dbf9f3bb0a96ec99188e6b54a797
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
This commit is contained in:
Stephen Finucane
2026-01-19 09:11:29 +01:00
parent 010b79efcb
commit 6a54bd92db
15 changed files with 472 additions and 321 deletions

View File

@@ -12,18 +12,14 @@ repos:
- id: debug-statements
- id: check-yaml
files: .*\.(yaml|yml)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.13
hooks:
- id: ruff-check
args: ['--fix', '--unsafe-fixes']
- id: ruff-format
- repo: https://opendev.org/openstack/hacking
rev: 7.0.0
rev: 8.0.0
hooks:
- id: hacking
additional_dependencies: []
- repo: https://github.com/PyCQA/bandit
rev: 1.8.6
hooks:
- id: bandit
args: ['-c', 'pyproject.toml']
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py310-plus]

View File

@@ -129,8 +129,7 @@ else:
def set_keepcaps(enable):
"""Set/unset thread's "keep capabilities" flag - see prctl(2)"""
ret = _prctl(crt.PR_SET_KEEPCAPS,
ffi.cast('unsigned long', bool(enable)))
ret = _prctl(crt.PR_SET_KEEPCAPS, ffi.cast('unsigned long', bool(enable)))
if ret != 0:
errno = ffi.errno
raise OSError(errno, os.strerror(errno))
@@ -142,15 +141,16 @@ def drop_all_caps_except(effective, permitted, inheritable):
prm = _caps_to_mask(permitted)
inh = _caps_to_mask(inheritable)
header = ffi.new('cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2,
'pid': 0})
header = ffi.new(
'cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2, 'pid': 0},
)
data = ffi.new('struct __user_cap_data_struct[2]')
data[0].effective = eff & 0xffffffff
data[0].effective = eff & 0xFFFFFFFF
data[1].effective = eff >> 32
data[0].permitted = prm & 0xffffffff
data[0].permitted = prm & 0xFFFFFFFF
data[1].permitted = prm >> 32
data[0].inheritable = inh & 0xffffffff
data[0].inheritable = inh & 0xFFFFFFFF
data[1].inheritable = inh >> 32
ret = _capset(header, data)
@@ -174,9 +174,10 @@ def _caps_to_mask(caps):
def get_caps():
"""Return (effective, permitted, inheritable) as lists of caps"""
header = ffi.new('cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2,
'pid': 0})
header = ffi.new(
'cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2, 'pid': 0},
)
data = ffi.new('struct __user_cap_data_struct[2]')
ret = _capget(header, data)
if ret != 0:
@@ -184,10 +185,7 @@ def get_caps():
raise OSError(errno, os.strerror(errno))
return (
_mask_to_caps(data[0].effective |
(data[1].effective << 32)),
_mask_to_caps(data[0].permitted |
(data[1].permitted << 32)),
_mask_to_caps(data[0].inheritable |
(data[1].inheritable << 32)),
_mask_to_caps(data[0].effective | (data[1].effective << 32)),
_mask_to_caps(data[0].permitted | (data[1].permitted << 32)),
_mask_to_caps(data[0].inheritable | (data[1].inheritable << 32)),
)

View File

@@ -38,6 +38,7 @@ LOG = logging.getLogger(__name__)
@enum.unique
class Message(enum.IntEnum):
"""Types of messages sent across the communication channel"""
PING = 1
PONG = 2
CALL = 3
@@ -55,8 +56,9 @@ class Serializer:
self.writesock = writesock
def send(self, msg):
buf = msgpack.packb(msg, use_bin_type=True,
unicode_errors='surrogateescape')
buf = msgpack.packb(
msg, use_bin_type=True, unicode_errors='surrogateescape'
)
self.writesock.sendall(buf)
def close(self):
@@ -120,13 +122,17 @@ class Future:
before = datetime.datetime.now()
if not self.condvar.wait(timeout=self.timeout):
now = datetime.datetime.now()
LOG.warning('Timeout while executing a command, timeout: %s, '
'time elapsed: %s', self.timeout,
(now - before).total_seconds())
return (Message.ERR.value,
'{}.{}'.format(PrivsepTimeout.__module__,
PrivsepTimeout.__name__),
'')
LOG.warning(
'Timeout while executing a command, timeout: %s, '
'time elapsed: %s',
self.timeout,
(now - before).total_seconds(),
)
return (
Message.ERR.value,
f'{PrivsepTimeout.__module__}.{PrivsepTimeout.__name__}',
'',
)
if self.error is not None:
raise self.error
return self.data
@@ -158,8 +164,10 @@ class ClientChannel:
else:
with self.lock:
if msgid not in self.outstanding_msgs:
LOG.warning("msgid should be in oustanding_msgs, it is"
"possible that timeout is reached!")
LOG.warning(
"msgid should be in oustanding_msgs, it is"
"possible that timeout is reached!"
)
continue
self.outstanding_msgs[msgid].set_result(data)
@@ -169,7 +177,7 @@ class ClientChannel:
# get an immediate similar error.
LOG.debug('EOF on privsep read channel')
exc = IOError(_('Premature eof waiting for privileged process'))
exc = OSError(_('Premature eof waiting for privileged process'))
with self.lock:
for mbox in self.outstanding_msgs.values():
mbox.set_exception(exc)
@@ -193,7 +201,7 @@ class ClientChannel:
reply = future.result()
except Exception:
LOG.warning(f"Unexpected error: {sys.exc_info()[0]}")
LOG.warning("Unexpected error: %s", sys.exc_info()[0])
raise
finally:
del self.outstanding_msgs[myid]

View File

@@ -76,8 +76,16 @@ if platform.system() == 'Linux':
LOG = logging.getLogger(__name__)
EVENTLET_MODULES = ('os', 'select', 'socket', 'thread', 'time', 'MySQLdb',
'builtins', 'subprocess')
EVENTLET_MODULES = (
'os',
'select',
'socket',
'thread',
'time',
'MySQLdb',
'builtins',
'subprocess',
)
EVENTLET_LIBRARIES = []
@@ -89,17 +97,18 @@ _MONKEY_PATCHED = False
for module in EVENTLET_MODULES:
if eventlet.patcher.is_monkey_patched(module):
_MONKEY_PATCHED = True
if hasattr(patcher, '_green_%s_modules' % module):
method = getattr(patcher, '_green_%s_modules' % module)
elif hasattr(patcher, '_green_%s' % module):
method = getattr(patcher, '_green_%s' % module)
if hasattr(patcher, f'_green_{module}_modules'):
method = getattr(patcher, f'_green_{module}_modules')
elif hasattr(patcher, f'_green_{module}'):
method = getattr(patcher, f'_green_{module}')
else:
method = _null()
EVENTLET_LIBRARIES.append((module, method))
if _MONKEY_PATCHED:
debtcollector.deprecate(
"Eventlet support is deprecated and will be removed")
"Eventlet support is deprecated and will be removed"
)
@enum.unique
@@ -201,8 +210,9 @@ class _ClientChannel(comm.ClientChannel):
reply = self.send_recv((comm.Message.PING.value,))
success = reply[0] == comm.Message.PONG
except Exception as e:
self.log.exception('Error while sending initial PING to privsep: '
'%s', e)
self.log.exception(
'Error while sending initial PING to privsep: %s', e
)
success = False
if not success:
msg = _('Privsep daemon failed to start')
@@ -210,8 +220,9 @@ class _ClientChannel(comm.ClientChannel):
raise FailedToDropPrivileges(msg)
def remote_call(self, name, args, kwargs, timeout):
result = self.send_recv((comm.Message.CALL.value, name, args, kwargs),
timeout)
result = self.send_recv(
(comm.Message.CALL.value, name, args, kwargs), timeout
)
if result[0] == comm.Message.RET:
# (RET, return value)
return result[1]
@@ -234,14 +245,17 @@ class _ClientChannel(comm.ClientChannel):
def out_of_band(self, msg):
if msg[0] == comm.Message.LOG:
# (LOG, LogRecord __dict__)
message = {encodeutils.safe_decode(k): v
for k, v in msg[1].items()}
message = {
encodeutils.safe_decode(k): v for k, v in msg[1].items()
}
record = pylogging.makeLogRecord(message)
if self.log.isEnabledFor(record.levelno):
self.log.logger.handle(record)
else:
self.log.warning('Ignoring unexpected OOB message from privileged '
'process: %r', msg)
self.log.warning(
'Ignoring unexpected OOB message from privileged process: %r',
msg,
)
def fdopen(fd, *args, **kwargs):
@@ -266,10 +280,8 @@ def _fd_logger(level=logging.WARN):
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 = threading.Thread(name='fd_logger', target=logger, args=(read_end,))
t.daemon = True
t.start()
@@ -326,8 +338,9 @@ class ForkingClientChannel(_ClientChannel):
sock_a.close()
# Replace root logger early (to capture any errors during setup)
replace_logging(PrivsepLogHandler(channel,
processName=str(context)))
replace_logging(
PrivsepLogHandler(channel, processName=str(context))
)
Daemon(channel, context=context).run()
LOG.debug('privsep daemon exiting')
@@ -365,10 +378,14 @@ class RootwrapClientChannel(_ClientChannel):
cmd = context.helper_command(sockpath)
LOG.info('Running privsep helper: %s', cmd)
proc = subprocess.Popen(cmd, shell=False, stderr=_fd_logger())
proc = subprocess.Popen( # noqa: S603
cmd, shell=False, stderr=_fd_logger()
)
if proc.wait() != 0:
msg = ('privsep helper command exited non-zero (%s)' %
proc.returncode)
msg = (
f'privsep helper command exited non-zero '
f'({proc.returncode})'
)
LOG.critical(msg)
raise FailedToDropPrivileges(msg)
LOG.info('Spawned new privsep daemon via rootwrap')
@@ -399,7 +416,8 @@ class Daemon:
self.group = context.conf.group
self.caps = set(context.conf.capabilities)
self.thread_pool = futures.ThreadPoolExecutor(
context.conf.thread_pool_size)
context.conf.thread_pool_size
)
self.communication_error = None
def run(self):
@@ -437,16 +455,17 @@ class Daemon:
finally:
capabilities.set_keepcaps(False)
LOG.info('privsep process running with uid/gid: %(uid)s/%(gid)s',
{'uid': os.getuid(), 'gid': os.getgid()})
LOG.info(
'privsep process running with uid/gid: %(uid)s/%(gid)s',
{'uid': os.getuid(), 'gid': os.getgid()},
)
capabilities.drop_all_caps_except(self.caps, self.caps, [])
def fmt_caps(capset):
if not capset:
return 'none'
fc = [capabilities.CAPS_BYVALUE.get(c, str(c))
for c in capset]
fc = [capabilities.CAPS_BYVALUE.get(c, str(c)) for c in capset]
fc.sort()
return '|'.join(fc)
@@ -458,7 +477,8 @@ class Daemon:
'eff': fmt_caps(eff),
'prm': fmt_caps(prm),
'inh': fmt_caps(inh),
})
},
)
def _process_cmd(self, msgid, cmd, *args):
"""Executes the requested command in an execution thread.
@@ -490,12 +510,18 @@ class Daemon:
return (comm.Message.RET.value, ret)
except Exception as e:
LOG.debug(
'privsep: Exception during request[%(msgid)s]: '
'%(err)s', {'msgid': msgid, 'err': e}, exc_info=True)
'privsep: Exception during request[%(msgid)s]: %(err)s',
{'msgid': msgid, 'err': e},
exc_info=True,
)
cls = e.__class__
cls_name = f'{cls.__module__}.{cls.__name__}'
return (comm.Message.ERR.value, cls_name, e.args,
traceback.format_exc())
return (
comm.Message.ERR.value,
cls_name,
e.args,
traceback.format_exc(),
)
def _create_done_callback(self, msgid):
"""Creates a future callback to receive command execution results.
@@ -512,19 +538,27 @@ class Daemon:
"""
try:
reply = result.result()
LOG.debug('privsep: reply[%(msgid)s]: %(reply)s',
{'msgid': msgid, 'reply': reply})
LOG.debug(
'privsep: reply[%(msgid)s]: %(reply)s',
{'msgid': msgid, 'reply': reply},
)
channel.send((msgid, reply))
except OSError:
self.communication_error = sys.exc_info()
except Exception as e:
LOG.debug(
'privsep: Exception during request[%(msgid)s]: '
'%(err)s', {'msgid': msgid, 'err': e}, exc_info=True)
'privsep: Exception during request[%(msgid)s]: %(err)s',
{'msgid': msgid, 'err': e},
exc_info=True,
)
cls = e.__class__
cls_name = f'{cls.__module__}.{cls.__name__}'
reply = (comm.Message.ERR.value, cls_name, e.args,
traceback.format_exc())
reply = (
comm.Message.ERR.value,
cls_name,
e.args,
traceback.format_exc(),
)
try:
channel.send((msgid, reply))
except OSError as exc:
@@ -558,10 +592,12 @@ class 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),
])
cfg.CONF.register_cli_opts(
[
cfg.StrOpt('privsep_context', required=True),
cfg.StrOpt('privsep_sock_path', required=True),
]
)
logging.register_options(cfg.CONF)
@@ -570,10 +606,13 @@ def helper_main():
logging.setup(cfg.CONF, 'privsep', fix_eventlet=False)
context = importutils.import_class(cfg.CONF.privsep_context)
from oslo_privsep import priv_context # Avoid circular import
from oslo_privsep import priv_context # Avoid circular import
if not isinstance(context, priv_context.PrivContext):
LOG.fatal('--privsep_context must be the (python) name of a '
'PrivContext object')
LOG.fatal(
'--privsep_context must be the (python) name of a '
'PrivContext object'
)
sock = socket.socket(socket.AF_UNIX)
sock.connect(cfg.CONF.privsep_sock_path)

View File

@@ -36,14 +36,14 @@ test_context_with_timeout = priv_context.PrivContext(
cfg_section='privsep',
pypath=__name__ + '.test_context_with_timeout',
capabilities=[],
timeout=0.03
timeout=0.03,
)
@test_context.entrypoint
def sleep():
# We don't want the daemon to be able to handle these calls too fast.
time.sleep(.001)
time.sleep(0.001)
@test_context.entrypoint_with_timeout(0.03)
@@ -65,7 +65,7 @@ def one():
@test_context.entrypoint
def logs():
logging.warning('foo')
logging.warning('foo') # noqa: LOG015
class TestDaemon(base.BaseTestCase):
@@ -75,7 +75,8 @@ class TestDaemon(base.BaseTestCase):
self.cfg_fixture = self.useFixture(config_fixture.Config())
self.cfg_fixture.config(
group='privsep',
helper_command='sudo -E %s/bin/privsep-helper' % venv_path)
helper_command=f'sudo -E {venv_path}/bin/privsep-helper',
)
priv_context.init()
def test_concurrency(self):

View File

@@ -41,37 +41,55 @@ def CapNameOrInt(value):
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.Opt('capabilities',
type=types.List(CapNameOrInt), default=[],
help=_('List of Linux capabilities retained by the privsep '
'daemon.')),
cfg.IntOpt('thread_pool_size',
min=1,
help=_("The number of threads available for privsep to "
"concurrently run processes. Defaults to the number of "
"CPU cores in the system."),
default=multiprocessing.cpu_count(),
sample_default='multiprocessing.cpu_count()'),
cfg.StrOpt('helper_command',
help=_('Command to invoke to start the privsep daemon if '
'not using the "fork" method. '
'If not specified, a default is generated using '
'"sudo privsep-helper" and arguments designed to '
'recreate the current configuration. '
'This command must accept suitable --privsep_context '
'and --privsep_sock_path arguments.')),
cfg.StrOpt('logger_name',
help=_('Logger name to use for this privsep context. By '
'default all contexts log with oslo_privsep.daemon.'),
default='oslo_privsep.daemon'),
cfg.BoolOpt('log_daemon_traceback',
help=_('Print the exception traceback happened in the daemon '
'in the client logger'),
default=False),
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.Opt(
'capabilities',
type=types.List(CapNameOrInt),
default=[],
help=_('List of Linux capabilities retained by the privsep daemon.'),
),
cfg.IntOpt(
'thread_pool_size',
min=1,
help=_(
"The number of threads available for privsep to "
"concurrently run processes. Defaults to the number of "
"CPU cores in the system."
),
default=multiprocessing.cpu_count(),
sample_default='multiprocessing.cpu_count()',
),
cfg.StrOpt(
'helper_command',
help=_(
'Command to invoke to start the privsep daemon if '
'not using the "fork" method. '
'If not specified, a default is generated using '
'"sudo privsep-helper" and arguments designed to '
'recreate the current configuration. '
'This command must accept suitable --privsep_context '
'and --privsep_sock_path arguments.'
),
),
cfg.StrOpt(
'logger_name',
help=_(
'Logger name to use for this privsep context. By '
'default all contexts log with oslo_privsep.daemon.'
),
default='oslo_privsep.daemon',
),
cfg.BoolOpt(
'log_daemon_traceback',
help=_(
'Print the exception traceback happened in the daemon '
'in the client logger'
),
default=False,
),
]
_ENTRYPOINT_ATTR = 'privsep_entrypoint'
@@ -95,13 +113,14 @@ def _list_opts():
:returns: a list of (group_name, opts) tuples
"""
# This is the default group name, but that can be overridden by the caller
group = cfg.OptGroup('privsep',
title='oslo.privsep options',
help='Configuration options for the oslo.privsep '
'daemon. Note that this group name can be '
'changed by the consuming service. Check the '
'service\'s docs to see if this is the case.'
)
group = cfg.OptGroup(
'privsep',
title='oslo.privsep options',
help='Configuration options for the oslo.privsep '
'daemon. Note that this group name can be '
'changed by the consuming service. Check the '
'service\'s docs to see if this is the case.',
)
return [(group, copy.deepcopy(OPTS))]
@@ -130,10 +149,15 @@ def init(root_helper=None):
class PrivContext:
def __init__(self, prefix, cfg_section='privsep', pypath=None,
capabilities=None, logger_name='oslo_privsep.daemon',
timeout=None):
def __init__(
self,
prefix,
cfg_section='privsep',
pypath=None,
capabilities=None,
logger_name='oslo_privsep.daemon',
timeout=None,
):
# Note that capabilities=[] means retaining no capabilities
# and leaves even uid=0 with no powers except being able to
# read/write to the filesystem as uid=0. This might be what
@@ -153,10 +177,12 @@ class PrivContext:
self.start_lock = threading.Lock()
cfg.CONF.register_opts(OPTS, group=cfg_section)
cfg.CONF.set_default('capabilities', group=cfg_section,
default=capabilities)
cfg.CONF.set_default('logger_name', group=cfg_section,
default=logger_name)
cfg.CONF.set_default(
'capabilities', group=cfg_section, default=capabilities
)
cfg.CONF.set_default(
'logger_name', group=cfg_section, default=logger_name
)
self.timeout = timeout
@property
@@ -167,7 +193,7 @@ class PrivContext:
return cfg.CONF[self.cfg_section]
def __repr__(self):
return 'PrivContext(cfg_section=%s)' % self.cfg_section
return f'PrivContext(cfg_section={self.cfg_section})'
def helper_command(self, sockpath):
# We need to be able to reconstruct the context object in the new
@@ -180,11 +206,14 @@ class PrivContext:
# These asserts here are just attempts to catch errors earlier.
# TODO(gus): Consider replacing with setuptools entry_points.
if self.pypath is None:
raise AssertionError('helper_command requires priv_context '
'pypath to be specified')
raise AssertionError(
'helper_command requires priv_context pypath to be specified'
)
if importutils.import_class(self.pypath) is not self:
raise AssertionError('helper_command requires priv_context '
'pypath for context object')
raise AssertionError(
'helper_command requires priv_context '
'pypath for context object'
)
# Note order is important here. Deployments will (hopefully)
# have the exact arguments in sudoers/rootwrap configs and
@@ -209,8 +238,8 @@ class PrivContext:
pass
cmd.extend(
['--privsep_context', self.pypath,
'--privsep_sock_path', sockpath])
['--privsep_context', self.pypath, '--privsep_sock_path', sockpath]
)
return cmd
@@ -225,19 +254,21 @@ class PrivContext:
"""This is intended to be used as a decorator with timeout."""
def wrap(func):
@functools.wraps(func)
def inner(*args, **kwargs):
f = self._entrypoint(func)
return f(*args, _wrap_timeout=timeout, **kwargs)
setattr(inner, _ENTRYPOINT_ATTR, self)
return inner
return wrap
def _entrypoint(self, func):
if not func.__module__.startswith(self.prefix):
raise AssertionError('%r entrypoints must be below "%s"' %
(self, self.prefix))
raise AssertionError(
f'{self!r} entrypoints must be below "{self.prefix}"'
)
# Right now, we only track a single context in
# _ENTRYPOINT_ATTR. This could easily be expanded into a set,
@@ -245,8 +276,9 @@ class PrivContext:
# someone has a need to associate the same entrypoint with
# multiple contexts.
if getattr(func, _ENTRYPOINT_ATTR, None) is not None:
raise AssertionError('%r is already associated with another '
'PrivContext' % func)
raise AssertionError(
f'{func!r} is already associated with another PrivContext'
)
f = functools.partial(self._wrap, func)
setattr(f, _ENTRYPOINT_ATTR, self)
@@ -264,8 +296,7 @@ class PrivContext:
if self.channel is None:
self.start()
r_call_timeout = _wrap_timeout or self.timeout
return self.channel.remote_call(name, args, kwargs,
r_call_timeout)
return self.channel.remote_call(name, args, kwargs, r_call_timeout)
else:
return func(*args, **kwargs)
@@ -280,7 +311,7 @@ class PrivContext:
elif method is Method.FORK:
channel = daemon.ForkingClientChannel(context=self)
else:
raise ValueError('Unknown method: %s' % method)
raise ValueError(f'Unknown method: {method}')
self.channel = channel

View File

@@ -12,12 +12,11 @@
# License for the specific language governing permissions and limitations
# under the License.
import fixtures
import logging
import os
import sys
import fixtures
from oslo_config import fixture as cfg_fixture
from oslo_privsep import priv_context
@@ -34,11 +33,11 @@ class UnprivilegedPrivsepFixture(fixtures.Fixture):
super().setUp()
self.conf = self.useFixture(cfg_fixture.Config()).conf
self.conf.set_override('capabilities', [],
group=self.context.cfg_section)
self.conf.set_override(
'capabilities', [], group=self.context.cfg_section
)
for k in ('user', 'group'):
self.conf.set_override(
k, None, group=self.context.cfg_section)
self.conf.set_override(k, None, group=self.context.cfg_section)
for k, v in self.config_override.items():
self.conf.set_override(k, v, group='privsep')

View File

@@ -20,7 +20,6 @@ from oslo_privsep import capabilities
class TestCapabilities(base.BaseTestCase):
@mock.patch('oslo_privsep.capabilities._prctl')
def test_set_keepcaps_error(self, mock_prctl):
mock_prctl.return_value = -1
@@ -36,13 +35,15 @@ class TestCapabilities(base.BaseTestCase):
self.assertEqual(1, mock_prctl.call_count)
self.assertCountEqual(
[8, 1], # [PR_SET_KEEPCAPS, true]
[int(x) for x in mock_prctl.call_args[0]])
[int(x) for x in mock_prctl.call_args[0]],
)
@mock.patch('oslo_privsep.capabilities._capset')
def test_drop_all_caps_except_error(self, mock_capset):
mock_capset.return_value = -1
self.assertRaises(
OSError, capabilities.drop_all_caps_except, [0], [0], [0])
OSError, capabilities.drop_all_caps_except, [0], [0], [0]
)
@mock.patch('oslo_privsep.capabilities._capset')
def test_drop_all_caps_except(self, mock_capset):
@@ -50,12 +51,15 @@ class TestCapabilities(base.BaseTestCase):
# Somewhat arbitrary bit patterns to exercise _caps_to_mask
capabilities.drop_all_caps_except(
(17, 24, 49), (8, 10, 35, 56), (24, 31, 40))
(17, 24, 49), (8, 10, 35, 56), (24, 31, 40)
)
self.assertEqual(1, mock_capset.call_count)
hdr, data = mock_capset.call_args[0]
self.assertEqual(0x20071026, # _LINUX_CAPABILITY_VERSION_2
hdr.version)
self.assertEqual(
0x20071026, # _LINUX_CAPABILITY_VERSION_2
hdr.version,
)
self.assertEqual(0x01020000, data[0].effective)
self.assertEqual(0x00020000, data[1].effective)
self.assertEqual(0x00000500, data[0].permitted)
@@ -79,10 +83,10 @@ class TestCapabilities(base.BaseTestCase):
data[0].inheritable = 0x81000000
data[1].inheritable = 0x00000100
return 0
mock_capget.side_effect = impl
self.assertCountEqual(
([17, 24, 49],
[8, 10, 35, 56],
[24, 31, 40]),
capabilities.get_caps())
([17, 24, 49], [8, 10, 35, 56], [24, 31, 40]),
capabilities.get_caps(),
)

View File

@@ -51,7 +51,9 @@ def get_fake_context(conf_attrs=None, **context_attrs):
context.conf.group = 84
context.conf.thread_pool_size = 10
context.conf.capabilities = [
capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN]
capabilities.CAP_SYS_ADMIN,
capabilities.CAP_NET_ADMIN,
]
context.conf.logger_name = 'oslo_privsep.daemon'
vars(context).update(context_attrs)
vars(context.conf).update(conf_attrs)
@@ -88,12 +90,12 @@ class LogRecorder(pylogging.Formatter):
return super().format(record)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class LogTest(testctx.TestContextTestCase):
def test_priv_loglevel(self):
logger = self.useFixture(fixtures.FakeLogger(
level=logging.INFO))
logger = self.useFixture(fixtures.FakeLogger(level=logging.INFO))
# These write to the log on the priv side
logme(logging.DEBUG, 'test@DEBUG')
@@ -109,15 +111,19 @@ class LogTest(testctx.TestContextTestCase):
def test_record_data(self):
logs = []
self.useFixture(fixtures.FakeLogger(
level=logging.INFO, format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs)))
self.useFixture(
fixtures.FakeLogger(
level=logging.INFO,
format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs),
)
)
try:
logme(logging.WARN, 'test with exc', exc_info=True)
except Exception:
except Exception: # noqa: S110
pass
time.sleep(0.1) # Hack to give logging thread a chance to run
@@ -128,8 +134,9 @@ class LogTest(testctx.TestContextTestCase):
self.assertIn('test with exc', record.getMessage())
self.assertIsNone(record.exc_info)
self.assertIn('TestException: with arg', record.exc_text)
self.assertEqual('PrivContext(cfg_section=privsep)',
record.processName)
self.assertEqual(
'PrivContext(cfg_section=privsep)', record.processName
)
self.assertIn('test_daemon.py', record.exc_text)
self.assertEqual(logging.WARN, record.levelno)
self.assertEqual('logme', record.funcName)
@@ -137,11 +144,15 @@ class LogTest(testctx.TestContextTestCase):
def test_format_record(self):
logs = []
self.useFixture(fixtures.FakeLogger(
level=logging.INFO, format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs)))
self.useFixture(
fixtures.FakeLogger(
level=logging.INFO,
format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs),
)
)
logme(logging.WARN, 'test with exc', exc_info=True)
@@ -152,13 +163,15 @@ class LogTest(testctx.TestContextTestCase):
record = logs[0]
# Verify the log record can be formatted by ContextFormatter
fake_config = mock.Mock(
logging_default_format_string="NOCTXT: %(message)s")
logging_default_format_string="NOCTXT: %(message)s"
)
formatter = formatters.ContextFormatter(config=fake_config)
formatter.format(record)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class LogTestDaemonTraceback(testctx.TestContextTestCase):
def setUp(self):
self.config_override = {'log_daemon_traceback': True}
@@ -166,13 +179,18 @@ class LogTestDaemonTraceback(testctx.TestContextTestCase):
def test_record_daemon_traceback(self):
self.privsep_conf.set_override(
'log_daemon_traceback', True, group='privsep')
'log_daemon_traceback', True, group='privsep'
)
logs = []
self.useFixture(fixtures.FakeLogger(
level=logging.INFO, format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs)))
self.useFixture(
fixtures.FakeLogger(
level=logging.INFO,
format='dummy',
# fixtures.FakeLogger accepts only a formatter
# class/function, not an instance :(
formatter=functools.partial(LogRecorder, logs),
)
)
self.assertRaises(RuntimeError, raise_runtimeerror)
time.sleep(0.1) # Hack to give logging thread a chance to run
@@ -185,17 +203,23 @@ class LogTestDaemonTraceback(testctx.TestContextTestCase):
self.assertEqual(logging.WARN, record.levelno)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class DaemonTest(base.BaseTestCase):
@mock.patch('os.setuid')
@mock.patch('os.setgid')
@mock.patch('os.setgroups')
@mock.patch('oslo_privsep.capabilities.set_keepcaps')
@mock.patch('oslo_privsep.capabilities.drop_all_caps_except')
def test_drop_privs(self, mock_dropcaps, mock_keepcaps,
mock_setgroups, mock_setgid, mock_setuid):
def test_drop_privs(
self,
mock_dropcaps,
mock_keepcaps,
mock_setgroups,
mock_setgid,
mock_setuid,
):
channel = mock.NonCallableMock()
context = get_fake_context()
@@ -211,30 +235,33 @@ class DaemonTest(base.BaseTestCase):
mock_setgid.assert_called_once_with(84)
mock_setgroups.assert_called_once_with([])
assert manager.mock_calls == expected_calls
self.assertEqual(expected_calls, manager.mock_calls)
self.assertCountEqual(
[mock.call(True), mock.call(False)],
mock_keepcaps.mock_calls)
[mock.call(True), mock.call(False)], mock_keepcaps.mock_calls
)
mock_dropcaps.assert_called_once_with(
{capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN},
{capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN},
[])
[],
)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class WithContextTest(testctx.TestContextTestCase):
def test_unexported(self):
self.assertRaisesRegex(
NameError, 'undecorated not exported',
testctx.context._wrap, undecorated)
NameError,
'undecorated not exported',
testctx.context._wrap,
undecorated,
)
class ClientChannelTestCase(base.BaseTestCase):
DICT = {
'string_1': ('tuple_1', b'tuple_2'),
b'byte_1': ['list_1', 'list_2'],
@@ -248,22 +275,28 @@ class ClientChannelTestCase(base.BaseTestCase):
def setUp(self):
super().setUp()
context = get_fake_context()
with mock.patch.object(comm.ClientChannel, '__init__'), \
mock.patch.object(daemon._ClientChannel, 'exchange_ping'):
with (
mock.patch.object(comm.ClientChannel, '__init__'),
mock.patch.object(daemon._ClientChannel, 'exchange_ping'),
):
self.client_channel = daemon._ClientChannel(mock.ANY, context)
@mock.patch.object(daemon.LOG.logger, 'handle')
def test_out_of_band_log_message(self, handle_mock):
message = [comm.Message.LOG, self.DICT]
self.assertEqual(self.client_channel.log, daemon.LOG)
with mock.patch.object(pylogging, 'makeLogRecord') as mock_make_log, \
mock.patch.object(daemon.LOG, 'isEnabledFor',
return_value=True) as mock_enabled:
with (
mock.patch.object(pylogging, 'makeLogRecord') as mock_make_log,
mock.patch.object(
daemon.LOG, 'isEnabledFor', return_value=True
) as mock_enabled,
):
self.client_channel.out_of_band(message)
mock_make_log.assert_called_once_with(self.EXPECTED)
handle_mock.assert_called_once_with(mock_make_log.return_value)
mock_enabled.assert_called_once_with(
mock_make_log.return_value.levelno)
mock_make_log.return_value.levelno
)
def test_out_of_band_not_log_message(self):
with mock.patch.object(daemon.LOG, 'warning') as mock_warning:
@@ -272,12 +305,15 @@ class ClientChannelTestCase(base.BaseTestCase):
@mock.patch.object(daemon.logging, 'getLogger')
@mock.patch.object(pylogging, 'makeLogRecord')
def test_out_of_band_log_message_context_logger(self, make_log_mock,
get_logger_mock):
def test_out_of_band_log_message_context_logger(
self, make_log_mock, get_logger_mock
):
logger_name = 'os_brick.privileged'
context = get_fake_context(conf_attrs={'logger_name': logger_name})
with mock.patch.object(comm.ClientChannel, '__init__'), \
mock.patch.object(daemon._ClientChannel, 'exchange_ping'):
with (
mock.patch.object(comm.ClientChannel, '__init__'),
mock.patch.object(daemon._ClientChannel, 'exchange_ping'),
):
channel = daemon._ClientChannel(mock.ANY, context)
get_logger_mock.assert_called_once_with(logger_name)
@@ -288,22 +324,29 @@ class ClientChannelTestCase(base.BaseTestCase):
make_log_mock.assert_called_once_with(self.EXPECTED)
channel.log.isEnabledFor.assert_called_once_with(
make_log_mock.return_value.levelno)
make_log_mock.return_value.levelno
)
channel.log.logger.handle.assert_called_once_with(
make_log_mock.return_value)
make_log_mock.return_value
)
class UnMonkeyPatch(base.BaseTestCase):
def test_un_monkey_patch(self):
self.assertFalse(any(
eventlet.patcher.is_monkey_patched(eventlet_mod_name)
for eventlet_mod_name in daemon.EVENTLET_MODULES))
self.assertFalse(
any(
eventlet.patcher.is_monkey_patched(eventlet_mod_name)
for eventlet_mod_name in daemon.EVENTLET_MODULES
)
)
eventlet.monkey_patch()
self.assertTrue(any(
eventlet.patcher.is_monkey_patched(eventlet_mod_name)
for eventlet_mod_name in daemon.EVENTLET_MODULES))
self.assertTrue(
any(
eventlet.patcher.is_monkey_patched(eventlet_mod_name)
for eventlet_mod_name in daemon.EVENTLET_MODULES
)
)
daemon.un_monkey_patch()
for eventlet_mod_name, func_modules in daemon.EVENTLET_LIBRARIES:
@@ -314,7 +357,8 @@ class UnMonkeyPatch(base.BaseTestCase):
orig_mod = eventlet.patcher.original(name)
patched_mod = sys.modules.get(name)
for attr_name in green_mod.__patched__:
un_monkey_patched_attr = getattr(patched_mod, attr_name,
None)
un_monkey_patched_attr = getattr(
patched_mod, attr_name, None
)
original_attr = getattr(orig_mod, attr_name, None)
self.assertEqual(un_monkey_patched_attr, original_attr)

View File

@@ -66,10 +66,10 @@ def fail(custom=False):
raise RuntimeError("I can't let you do that Dave")
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class PrivContextTest(testctx.TestContextTestCase):
def test_set_client_mode(self):
context = priv_context.PrivContext('test', capabilities=[])
self.assertTrue(context.client_mode)
@@ -82,9 +82,12 @@ class PrivContextTest(testctx.TestContextTestCase):
_, temp_path = tempfile.mkstemp()
cmd = testctx.context.helper_command(temp_path)
expected = [
'foo', '--bar',
'--privsep_context', testctx.context.pypath,
'--privsep_sock_path', temp_path,
'foo',
'--bar',
'--privsep_context',
testctx.context.pypath,
'--privsep_sock_path',
temp_path,
]
self.assertEqual(expected, cmd)
@@ -93,11 +96,15 @@ class PrivContextTest(testctx.TestContextTestCase):
_, temp_path = tempfile.mkstemp()
cmd = testctx.context.helper_command(temp_path)
expected = [
'sudo', 'privsep-helper',
'--config-file', '/bar.conf',
'sudo',
'privsep-helper',
'--config-file',
'/bar.conf',
# --config-dir arg should be skipped
'--privsep_context', testctx.context.pypath,
'--privsep_sock_path', temp_path,
'--privsep_context',
testctx.context.pypath,
'--privsep_sock_path',
temp_path,
]
self.assertEqual(expected, cmd)
@@ -107,21 +114,31 @@ class PrivContextTest(testctx.TestContextTestCase):
_, temp_path = tempfile.mkstemp()
cmd = testctx.context.helper_command(temp_path)
expected = [
'sudo', 'privsep-helper',
'--config-file', '/bar.conf',
'--config-file', '/baz.conf',
'--config-dir', '/foo.d',
'--privsep_context', testctx.context.pypath,
'--privsep_sock_path', temp_path,
'sudo',
'privsep-helper',
'--config-file',
'/bar.conf',
'--config-file',
'/baz.conf',
'--config-dir',
'/foo.d',
'--privsep_context',
testctx.context.pypath,
'--privsep_sock_path',
temp_path,
]
self.assertEqual(expected, cmd)
def test_init_known_contexts(self):
self.assertEqual(testctx.context.helper_command('/sock')[:2],
['sudo', 'privsep-helper'])
self.assertEqual(
testctx.context.helper_command('/sock')[:2],
['sudo', 'privsep-helper'],
)
priv_context.init(root_helper=['sudo', 'rootwrap'])
self.assertEqual(testctx.context.helper_command('/sock')[:3],
['sudo', 'rootwrap', 'privsep-helper'])
self.assertEqual(
testctx.context.helper_command('/sock')[:3],
['sudo', 'rootwrap', 'privsep-helper'],
)
def test_start_acquires_lock(self):
context = priv_context.PrivContext('test', capabilities=[])
@@ -134,8 +151,9 @@ class PrivContextTest(testctx.TestContextTestCase):
self.assertTrue(context.start_lock.__enter__.called)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class SeparationTest(testctx.TestContextTestCase):
def test_getpid(self):
# Verify that priv_getpid() was executed in another process.
@@ -156,8 +174,9 @@ class SeparationTest(testctx.TestContextTestCase):
self.assertNotMyPid(priv_getpid())
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class RootwrapTest(testctx.TestContextTestCase):
def setUp(self):
super().setUp()
@@ -167,15 +186,18 @@ class RootwrapTest(testctx.TestContextTestCase):
# requiring it to be properly installed.
cmd = [
'env',
'PYTHON_PATH=%s' % os.path.pathsep.join(sys.path),
sys.executable, daemon.__file__,
f'PYTHON_PATH={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(shlex.quote, cmd)),
group=testctx.context.cfg_section)
'helper_command',
' '.join(map(shlex.quote, cmd)),
group=testctx.context.cfg_section,
)
testctx.context.start(method=priv_context.Method.ROOTWRAP)
@@ -185,25 +207,24 @@ class RootwrapTest(testctx.TestContextTestCase):
self.assertNotMyPid(priv_pid)
def test_long_call_with_timeout(self):
self.assertRaises(
comm.PrivsepTimeout,
do_some_long
)
self.assertRaises(comm.PrivsepTimeout, do_some_long)
def test_long_call_within_timeout(self):
res = do_some_long(0.001)
self.assertEqual(42, res)
@testtools.skipIf(platform.system() != 'Linux',
'works only on Linux platform.')
@testtools.skipIf(
platform.system() != 'Linux', 'works only on Linux platform.'
)
class SerializationTest(testctx.TestContextTestCase):
def test_basic_functionality(self):
self.assertEqual(43, add1(42))
def test_raises_standard(self):
self.assertRaisesRegex(
RuntimeError, "I can't let you do that Dave", fail)
RuntimeError, "I can't let you do that Dave", fail
)
def test_raises_custom(self):
exc = self.assertRaises(CustomError, fail, custom=True)

View File

@@ -35,8 +35,7 @@ class TestContextTestCase(base.BaseTestCase):
super().setUp()
config_override = getattr(self, 'config_override', {})
privsep_fixture = self.useFixture(
fixture.UnprivilegedPrivsepFixture(
context, config_override)
fixture.UnprivilegedPrivsepFixture(context, config_override)
)
self.privsep_conf = privsep_fixture.conf

View File

@@ -40,6 +40,12 @@ privsep-helper = "oslo_privsep.daemon:helper_main"
[tool.setuptools]
packages = ["oslo_privsep"]
[tool.bandit]
exclude_dirs = ["tests"]
skips = ['B404', 'B603']
[tool.ruff]
line-length = 79
[tool.ruff.format]
quote-style = "preserve"
docstring-code-format = true
[tool.ruff.lint]
select = ["E4", "E5", "E7", "E9", "F", "G", "LOG", "S", "UP"]

View File

@@ -197,9 +197,13 @@ htmlhelp_basename = 'oslo.privsepReleaseNotesDoc'
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'oslo.privsepReleaseNotes.tex',
'oslo.privsep Release Notes Documentation',
'oslo.privsep Developers', 'manual'),
(
'index',
'oslo.privsepReleaseNotes.tex',
'oslo.privsep Release Notes Documentation',
'oslo.privsep Developers',
'manual',
),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -228,9 +232,13 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'oslo.privsepReleaseNotes',
'oslo.privsep Release Notes Documentation',
['oslo.privsep Developers'], 1)
(
'index',
'oslo.privsepReleaseNotes',
'oslo.privsep Release Notes Documentation',
['oslo.privsep Developers'],
1,
)
]
# If true, show URL addresses after external links.
@@ -243,11 +251,15 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'oslo.privsepReleaseNotes',
'oslo.privsep Release Notes Documentation',
'oslo.privsep Developers', 'oslo.privsepReleaseNotes',
'OpenStack library for privilege separation.',
'Miscellaneous'),
(
'index',
'oslo.privsepReleaseNotes',
'oslo.privsep Release Notes Documentation',
'oslo.privsep Developers',
'oslo.privsepReleaseNotes',
'OpenStack library for privilege separation.',
'Miscellaneous',
),
]
# Documents to append as an appendix to all manuals.

View File

@@ -15,6 +15,4 @@
import setuptools
setuptools.setup(
setup_requires=['pbr>=2.0.0'],
pbr=True)
setuptools.setup(setup_requires=['pbr>=2.0.0'], pbr=True)

65
tox.ini
View File

@@ -9,26 +9,14 @@ deps =
-r{toxinidir}/requirements.txt
commands = stestr run --slowest {posargs}
[testenv:pep8]
skip_install = true
deps =
pre-commit>=2.6.0 # MIT
commands =
pre-commit run -a
[testenv:functional]
setenv =
OS_TEST_PATH=./oslo_privsep/functional
OS_LOG_CAPTURE=1
[testenv:venv]
commands = {posargs}
[testenv:docs]
allowlist_externals =
rm
deps =
{[testenv]deps}
-r{toxinidir}/doc/requirements.txt
commands =
rm -rf doc/build doc/source/reference/api
sphinx-build -W --keep-going -b html doc/source doc/build/html
[testenv:cover]
deps = {[testenv]deps}
coverage
@@ -42,21 +30,15 @@ commands =
coverage xml -o cover/coverage.xml
coverage report --show-missing
[flake8]
show-source = True
# H904: Delay string interpolations at logging calls
enable-extensions = H106,H203,H904
# E123, E125 skipped as they are invalid PEP-8.
# [H106] Dont put vim configuration in source files
# [H203] Use assertIs(Not)None to check for None
# [W504] line break after binary operator
ignore = E123,E125,W504
builtins = _
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build
[hacking]
import_exceptions =
oslo_privsep._i18n
[testenv:docs]
allowlist_externals =
rm
deps =
{[testenv]deps}
-r{toxinidir}/doc/requirements.txt
commands =
rm -rf doc/build doc/source/reference/api
sphinx-build -W --keep-going -b html doc/source doc/build/html
[testenv:releasenotes]
allowlist_externals =
@@ -68,7 +50,20 @@ commands =
rm -rf releasenotes/build
sphinx-build -a -E -W -d releasenotes/build/doctrees --keep-going -b html releasenotes/source releasenotes/build/html
[testenv:functional]
setenv =
OS_TEST_PATH=./oslo_privsep/functional
OS_LOG_CAPTURE=1
[testenv:pep8]
skip_install = true
deps =
pre-commit>=2.6.0 # MIT
commands =
pre-commit run -a
[flake8]
# We only enable the hacking (H) checks
select = H
show-source = True
builtins = _
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build
[hacking]
import_exceptions =
oslo_privsep._i18n