From 422243384b9d1fc19249eb8a40cbf5d900be163e Mon Sep 17 00:00:00 2001 From: Endre Karlson Date: Tue, 22 Jan 2013 10:27:54 +0100 Subject: [PATCH] Change to rootwrapper - bug #1075120 Change-Id: I2292d1a47772859904bd8f1bb1add30387abb481 --- bin/moniker-rootwrap | 125 ++++++++++++ etc/moniker.conf.sample | 7 +- etc/rootwrap.conf | 27 +++ etc/rootwrap.d/bind9.filters | 10 + moniker/backend/impl_bind9.py | 54 ++---- moniker/backend/impl_mysqlbind9.py | 10 +- moniker/openstack/common/processutils.py | 135 +++++++++++++ moniker/openstack/common/rootwrap/__init__.py | 16 ++ moniker/openstack/common/rootwrap/filters.py | 180 ++++++++++++++++++ moniker/openstack/common/rootwrap/wrapper.py | 149 +++++++++++++++ moniker/utils.py | 14 ++ openstack-common.conf | 2 +- setup.py | 3 +- 13 files changed, 686 insertions(+), 46 deletions(-) create mode 100755 bin/moniker-rootwrap create mode 100644 etc/rootwrap.conf create mode 100644 etc/rootwrap.d/bind9.filters create mode 100644 moniker/openstack/common/processutils.py create mode 100644 moniker/openstack/common/rootwrap/__init__.py create mode 100644 moniker/openstack/common/rootwrap/filters.py create mode 100644 moniker/openstack/common/rootwrap/wrapper.py diff --git a/bin/moniker-rootwrap b/bin/moniker-rootwrap new file mode 100755 index 000000000..a9eef278f --- /dev/null +++ b/bin/moniker-rootwrap @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# All Rights Reserved. +# +# 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. + +"""Root wrapper for OpenStack services + + Filters which commands a service is allowed to run as another user. + + To use this with moniker, you should set the following in moniker.conf: + rootwrap_config=/etc/moniker/rootwrap.conf + + You also need to let the moniker user run moniker-rootwrap as root in sudoers: + moniker ALL = (root) NOPASSWD: /usr/bin/moniker-rootwrap /etc/moniker/rootwrap.conf * + + Service packaging should deploy .filters files only on nodes where they are + needed, to avoid allowing more than is necessary. +""" + +import ConfigParser +import logging +import os +import pwd +import signal +import subprocess +import sys + + +RC_UNAUTHORIZED = 99 +RC_NOCOMMAND = 98 +RC_BADCONFIG = 97 +RC_NOEXECFOUND = 96 + + +def _subprocess_setup(): + # Python installs a SIGPIPE handler by default. This is usually not what + # non-Python subprocesses expect. + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + +def _exit_error(execname, message, errorcode, log=True): + print "%s: %s" % (execname, message) + if log: + logging.error(message) + sys.exit(errorcode) + + +if __name__ == '__main__': + # Split arguments, require at least a command + execname = sys.argv.pop(0) + if len(sys.argv) < 2: + _exit_error(execname, "No command specified", RC_NOCOMMAND, log=False) + + configfile = sys.argv.pop(0) + userargs = sys.argv[:] + + # Add ../ to sys.path to allow running from branch + possible_topdir = os.path.normpath(os.path.join(os.path.abspath(execname), + os.pardir, os.pardir)) + if os.path.exists(os.path.join(possible_topdir, "moniker", "__init__.py")): + sys.path.insert(0, possible_topdir) + + from moniker.openstack.common.rootwrap import wrapper + + # Load configuration + try: + rawconfig = ConfigParser.RawConfigParser() + rawconfig.read(configfile) + config = wrapper.RootwrapConfig(rawconfig) + except ValueError as exc: + msg = "Incorrect value in %s: %s" % (configfile, exc.message) + _exit_error(execname, msg, RC_BADCONFIG, log=False) + except ConfigParser.Error: + _exit_error(execname, "Incorrect configuration file: %s" % configfile, + RC_BADCONFIG, log=False) + + if config.use_syslog: + wrapper.setup_syslog(execname, + config.syslog_log_facility, + config.syslog_log_level) + + # Execute command if it matches any of the loaded filters + filters = wrapper.load_filters(config.filters_path) + try: + filtermatch = wrapper.match_filter(filters, userargs, + exec_dirs=config.exec_dirs) + if filtermatch: + command = filtermatch.get_command(userargs, + exec_dirs=config.exec_dirs) + if config.use_syslog: + logging.info("(%s > %s) Executing %s (filter match = %s)" % ( + os.getlogin(), pwd.getpwuid(os.getuid())[0], + command, filtermatch.name)) + + obj = subprocess.Popen(command, + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + preexec_fn=_subprocess_setup, + env=filtermatch.get_environment(userargs)) + obj.wait() + sys.exit(obj.returncode) + + except wrapper.FilterMatchNotExecutable as exc: + msg = ("Executable not found: %s (filter match = %s)" + % (exc.match.exec_path, exc.match.name)) + _exit_error(execname, msg, RC_NOEXECFOUND, log=config.use_syslog) + + except wrapper.NoFilterMatched: + msg = ("Unauthorized command: %s (no filter matched)" + % ' '.join(userargs)) + _exit_error(execname, msg, RC_UNAUTHORIZED, log=config.use_syslog) diff --git a/etc/moniker.conf.sample b/etc/moniker.conf.sample index 18e857e7b..e2c5177cc 100644 --- a/etc/moniker.conf.sample +++ b/etc/moniker.conf.sample @@ -17,6 +17,11 @@ debug = False # Driver used for issuing notifications #notification_driver = moniker.openstack.common.notifier.rabbit_notifier +# Use "sudo moniker-rootwrap /etc/moniker/rootwrap.conf" to use the real +# root filter facility. +# Change to "sudo" to skip the filtering and just run the comand directly +root_helper = sudo + # There has to be a better way to set these defaults allowed_rpc_exception_modules = moniker.exceptions, moniker.openstack.common.exception default_log_levels = amqplib=WARN, sqlalchemy=WARN, boto=WARN, suds=INFO, keystone=INFO, eventlet.wsgi.server=WARN, stevedore=WARN, keystoneclient.middleware.auth_token=INFO @@ -90,7 +95,6 @@ default_log_levels = amqplib=WARN, sqlalchemy=WARN, boto=WARN, suds=INFO, keysto # Bind9 Backend #----------------------- [backend:bind9] -#rndc_path = /usr/sbin/rndc #rndc_host = 127.0.0.1 #rndc_port = 953 #rndc_config_file = /etc/rndc.conf @@ -101,7 +105,6 @@ default_log_levels = amqplib=WARN, sqlalchemy=WARN, boto=WARN, suds=INFO, keysto #----------------------- [backend:mysqlbind9] #database_connection = mysql://user:password@host/schema -#rndc_path = /usr/sbin/rndc #rndc_host = 127.0.0.1 #rndc_port = 953 #rndc_config_file = /etc/rndc.conf diff --git a/etc/rootwrap.conf b/etc/rootwrap.conf new file mode 100644 index 000000000..8717ffb7f --- /dev/null +++ b/etc/rootwrap.conf @@ -0,0 +1,27 @@ +# Configuration for moniker-rootwrap +# This file should be owned by (and only-writeable by) the root user + +[DEFAULT] +# List of directories to load filter definitions from (separated by ','). +# These directories MUST all be only writeable by root ! +filters_path=/etc/moniker/rootwrap.d,/usr/share/moniker/rootwrap + +# List of directories to search executables in, in case filters do not +# explicitely specify a full path (separated by ',') +# If not specified, defaults to system PATH environment variable. +# These directories MUST all be only writeable by root ! +exec_dirs=/sbin,/usr/sbin,/bin,/usr/bin + +# Enable logging to syslog +# Default value is False +use_syslog=False + +# Which syslog facility to use. +# Valid values include auth, authpriv, syslog, user0, user1... +# Default value is 'syslog' +syslog_log_facility=syslog + +# Which messages to log. +# INFO means log all usage +# ERROR means only log unsuccessful attempts +syslog_log_level=ERROR diff --git a/etc/rootwrap.d/bind9.filters b/etc/rootwrap.d/bind9.filters new file mode 100644 index 000000000..582e244eb --- /dev/null +++ b/etc/rootwrap.d/bind9.filters @@ -0,0 +1,10 @@ +# moniker-rootwrap command filters for nodes on which moniker is +# expected to control network +# +# This file should be owned by (and only-writeable by) the root user + +# format seems to be +# cmd-name: filter-name, raw-command, user, args + +[Filters] +rndc: CommandFilter, /usr/sbin/rndc, root diff --git a/moniker/backend/impl_bind9.py b/moniker/backend/impl_bind9.py index 35ea7833a..b3f636531 100644 --- a/moniker/backend/impl_bind9.py +++ b/moniker/backend/impl_bind9.py @@ -14,7 +14,6 @@ # License for the specific language governing permissions and limitations # under the License. import os -import subprocess from moniker.openstack.common import cfg from moniker.openstack.common import log as logging from moniker import utils @@ -29,8 +28,6 @@ cfg.CONF.register_group(cfg.OptGroup( )) cfg.CONF.register_opts([ - cfg.StrOpt('rndc-path', default='/usr/sbin/rndc', - help='RNDC Path'), cfg.StrOpt('rndc-host', default='127.0.0.1', help='RNDC Host'), cfg.IntOpt('rndc-port', default=953, help='RNDC Port'), cfg.StrOpt('rndc-config-file', default=None, @@ -104,6 +101,21 @@ class Bind9Backend(base.Backend): domains=domains, state_path=abs_state_path) + def _rndc_base(self): + rndc_call = [ + 'rndc', + '-s', cfg.CONF[self.name].rndc_host, + '-p', str(cfg.CONF[self.name].rndc_port), + ] + + if cfg.CONF[self.name].rndc_config_file: + rndc_call.extend(['-c', cfg.CONF[self.name].rndc_config_file]) + + if cfg.CONF[self.name].rndc_key_file: + rndc_call.extend(['-k', cfg.CONF[self.name].rndc_key_file]) + + return rndc_call + """ Remove domain zone files and reload bind config """ def _sync_delete_domain(self, domain, new_domain_flag=False): """ delete a single domain's zone file """ @@ -119,23 +131,9 @@ class Bind9Backend(base.Backend): self._sync_domains() - rndc_call = [ - 'sudo', - cfg.CONF[self.name].rndc_path, - '-s', cfg.CONF[self.name].rndc_host, - '-p', str(cfg.CONF[self.name].rndc_port), - ] + rndc_call = self._rndc_base() + ['reload'] - if cfg.CONF[self.name].rndc_config_file: - rndc_call.extend(['-c', cfg.CONF[self.name].rndc_config_file]) - - if cfg.CONF[self.name].rndc_key_file: - rndc_call.extend(['-k', cfg.CONF[self.name].rndc_key_file]) - - rndc_call.extend(['reload']) - - LOG.debug('Calling RNDC with: %s' % " ".join(rndc_call)) - subprocess.call(rndc_call) + utils.execute(*rndc_call) """ Update the bind to read in new zone files or changes to existin """ def _sync_domain(self, domain, servers=None, new_domain_flag=False): @@ -163,24 +161,12 @@ class Bind9Backend(base.Backend): self._sync_domains() - rndc_call = [ - 'sudo', - cfg.CONF[self.name].rndc_path, - '-s', cfg.CONF[self.name].rndc_host, - '-p', str(cfg.CONF[self.name].rndc_port), - ] - - if cfg.CONF[self.name].rndc_config_file: - rndc_call.extend(['-c', cfg.CONF[self.name].rndc_config_file]) - - if cfg.CONF[self.name].rndc_key_file: - rndc_call.extend(['-k', cfg.CONF[self.name].rndc_key_file]) - rndc_op = 'reconfig' if new_domain_flag else 'reload' - rndc_call.extend([rndc_op]) + + rndc_call = self._rndc_base() + [rndc_op] if not new_domain_flag: rndc_call.extend([domain['name']]) LOG.debug('Calling RNDC with: %s' % " ".join(rndc_call)) - subprocess.call(rndc_call) + utils.execute(*rndc_call) diff --git a/moniker/backend/impl_mysqlbind9.py b/moniker/backend/impl_mysqlbind9.py index 035fa1bf7..78e05216c 100644 --- a/moniker/backend/impl_mysqlbind9.py +++ b/moniker/backend/impl_mysqlbind9.py @@ -16,7 +16,6 @@ # License for the specific language governing permissions and limitations # under the License. import os -import subprocess from moniker.openstack.common import cfg from moniker.openstack.common import log as logging from moniker import utils @@ -34,8 +33,6 @@ cfg.CONF.register_group(cfg.OptGroup( )) cfg.CONF.register_opts([ - cfg.StrOpt('rndc-path', - default='/usr/sbin/rndc', help='RNDC Path'), cfg.StrOpt('rndc-host', default='127.0.0.1', help='RNDC Host'), cfg.IntOpt('rndc-port', default=953, help='RNDC Port'), cfg.StrOpt('rndc-config-file', @@ -332,8 +329,7 @@ class MySQLBind9Backend(base.Backend): # only do this if domain create, domain delete rndc_call = [ - 'sudo', - cfg.CONF[self.name].rndc_path, + 'rndc', '-s', cfg.CONF[self.name].rndc_host, '-p', str(cfg.CONF[self.name].rndc_port), ] @@ -346,6 +342,4 @@ class MySQLBind9Backend(base.Backend): rndc_call.extend(['reconfig']) - LOG.warn(rndc_call) - - subprocess.call(rndc_call) + utils.execute(*rndc_call) diff --git a/moniker/openstack/common/processutils.py b/moniker/openstack/common/processutils.py new file mode 100644 index 000000000..f48289c70 --- /dev/null +++ b/moniker/openstack/common/processutils.py @@ -0,0 +1,135 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +System-level utilities and helper functions. +""" + +import logging +import random +import shlex + +from eventlet.green import subprocess +from eventlet import greenthread + +from moniker.openstack.common.gettextutils import _ + + +LOG = logging.getLogger(__name__) + + +class UnknownArgumentError(Exception): + def __init__(self, message=None): + super(UnknownArgumentError, self).__init__(message) + + +class ProcessExecutionError(Exception): + def __init__(self, stdout=None, stderr=None, exit_code=None, cmd=None, + description=None): + if description is None: + description = "Unexpected error while running command." + if exit_code is None: + exit_code = '-' + message = ("%s\nCommand: %s\nExit code: %s\nStdout: %r\nStderr: %r" + % (description, cmd, exit_code, stdout, stderr)) + super(ProcessExecutionError, self).__init__(message) + + +def execute(*cmd, **kwargs): + """ + Helper method to shell out and execute a command through subprocess with + optional retry. + + :param cmd: Passed to subprocess.Popen. + :type cmd: string + :param process_input: Send to opened process. + :type proces_input: string + :param check_exit_code: Defaults to 0. Will raise + :class:`ProcessExecutionError` + if the command exits without returning this value + as a returncode + :type check_exit_code: int + :param delay_on_retry: True | False. Defaults to True. If set to True, + wait a short amount of time before retrying. + :type delay_on_retry: boolean + :param attempts: How many times to retry cmd. + :type attempts: int + :param run_as_root: True | False. Defaults to False. If set to True, + the command is prefixed by the command specified + in the root_helper kwarg. + :type run_as_root: boolean + :param root_helper: command to prefix all cmd's with + :type root_helper: string + :returns: (stdout, stderr) from process execution + :raises: :class:`UnknownArgumentError` on + receiving unknown arguments + :raises: :class:`ProcessExecutionError` + """ + + process_input = kwargs.pop('process_input', None) + check_exit_code = kwargs.pop('check_exit_code', 0) + delay_on_retry = kwargs.pop('delay_on_retry', True) + attempts = kwargs.pop('attempts', 1) + run_as_root = kwargs.pop('run_as_root', False) + root_helper = kwargs.pop('root_helper', '') + if len(kwargs): + raise UnknownArgumentError(_('Got unknown keyword args ' + 'to utils.execute: %r') % kwargs) + if run_as_root: + cmd = shlex.split(root_helper) + list(cmd) + cmd = map(str, cmd) + + while attempts > 0: + attempts -= 1 + try: + LOG.debug(_('Running cmd (subprocess): %s'), ' '.join(cmd)) + _PIPE = subprocess.PIPE # pylint: disable=E1101 + obj = subprocess.Popen(cmd, + stdin=_PIPE, + stdout=_PIPE, + stderr=_PIPE, + close_fds=True) + result = None + if process_input is not None: + result = obj.communicate(process_input) + else: + result = obj.communicate() + obj.stdin.close() # pylint: disable=E1101 + _returncode = obj.returncode # pylint: disable=E1101 + if _returncode: + LOG.debug(_('Result was %s') % _returncode) + if (isinstance(check_exit_code, int) and + not isinstance(check_exit_code, bool) and + _returncode != check_exit_code): + (stdout, stderr) = result + raise ProcessExecutionError(exit_code=_returncode, + stdout=stdout, + stderr=stderr, + cmd=' '.join(cmd)) + return result + except ProcessExecutionError: + if not attempts: + raise + else: + LOG.debug(_('%r failed. Retrying.'), cmd) + if delay_on_retry: + greenthread.sleep(random.randint(20, 200) / 100.0) + finally: + # NOTE(termie): this appears to be necessary to let the subprocess + # call clean something up in between calls, without + # it two execute calls in a row hangs the second one + greenthread.sleep(0) diff --git a/moniker/openstack/common/rootwrap/__init__.py b/moniker/openstack/common/rootwrap/__init__.py new file mode 100644 index 000000000..671d3c173 --- /dev/null +++ b/moniker/openstack/common/rootwrap/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# All Rights Reserved. +# +# 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. diff --git a/moniker/openstack/common/rootwrap/filters.py b/moniker/openstack/common/rootwrap/filters.py new file mode 100644 index 000000000..905bbabea --- /dev/null +++ b/moniker/openstack/common/rootwrap/filters.py @@ -0,0 +1,180 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# All Rights Reserved. +# +# 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 re + + +class CommandFilter(object): + """Command filter only checking that the 1st argument matches exec_path""" + + def __init__(self, exec_path, run_as, *args): + self.name = '' + self.exec_path = exec_path + self.run_as = run_as + self.args = args + self.real_exec = None + + def get_exec(self, exec_dirs=[]): + """Returns existing executable, or empty string if none found""" + if self.real_exec is not None: + return self.real_exec + self.real_exec = "" + if self.exec_path.startswith('/'): + if os.access(self.exec_path, os.X_OK): + self.real_exec = self.exec_path + else: + for binary_path in exec_dirs: + expanded_path = os.path.join(binary_path, self.exec_path) + if os.access(expanded_path, os.X_OK): + self.real_exec = expanded_path + break + return self.real_exec + + def match(self, userargs): + """Only check that the first argument (command) matches exec_path""" + if (os.path.basename(self.exec_path) == userargs[0]): + return True + return False + + def get_command(self, userargs, exec_dirs=[]): + """Returns command to execute (with sudo -u if run_as != root).""" + to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path + if (self.run_as != 'root'): + # Used to run commands at lesser privileges + return ['sudo', '-u', self.run_as, to_exec] + userargs[1:] + return [to_exec] + userargs[1:] + + def get_environment(self, userargs): + """Returns specific environment to set, None if none""" + return None + + +class RegExpFilter(CommandFilter): + """Command filter doing regexp matching for every argument""" + + def match(self, userargs): + # Early skip if command or number of args don't match + if (len(self.args) != len(userargs)): + # DENY: argument numbers don't match + return False + # Compare each arg (anchoring pattern explicitly at end of string) + for (pattern, arg) in zip(self.args, userargs): + try: + if not re.match(pattern + '$', arg): + break + except re.error: + # DENY: Badly-formed filter + return False + else: + # ALLOW: All arguments matched + return True + + # DENY: Some arguments did not match + return False + + +class DnsmasqFilter(CommandFilter): + """Specific filter for the dnsmasq call (which includes env)""" + + CONFIG_FILE_ARG = 'CONFIG_FILE' + + def match(self, userargs): + if (userargs[0] == 'env' and + userargs[1].startswith(self.CONFIG_FILE_ARG) and + userargs[2].startswith('NETWORK_ID=') and + userargs[3] == 'dnsmasq'): + return True + return False + + def get_command(self, userargs, exec_dirs=[]): + to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path + dnsmasq_pos = userargs.index('dnsmasq') + return [to_exec] + userargs[dnsmasq_pos + 1:] + + def get_environment(self, userargs): + env = os.environ.copy() + env[self.CONFIG_FILE_ARG] = userargs[1].split('=')[-1] + env['NETWORK_ID'] = userargs[2].split('=')[-1] + return env + + +class DeprecatedDnsmasqFilter(DnsmasqFilter): + """Variant of dnsmasq filter to support old-style FLAGFILE""" + CONFIG_FILE_ARG = 'FLAGFILE' + + +class KillFilter(CommandFilter): + """Specific filter for the kill calls. + 1st argument is the user to run /bin/kill under + 2nd argument is the location of the affected executable + Subsequent arguments list the accepted signals (if any) + + This filter relies on /proc to accurately determine affected + executable, so it will only work on procfs-capable systems (not OSX). + """ + + def __init__(self, *args): + super(KillFilter, self).__init__("/bin/kill", *args) + + def match(self, userargs): + if userargs[0] != "kill": + return False + args = list(userargs) + if len(args) == 3: + # A specific signal is requested + signal = args.pop(1) + if signal not in self.args[1:]: + # Requested signal not in accepted list + return False + else: + if len(args) != 2: + # Incorrect number of arguments + return False + if len(self.args) > 1: + # No signal requested, but filter requires specific signal + return False + try: + command = os.readlink("/proc/%d/exe" % int(args[1])) + # NOTE(dprince): /proc/PID/exe may have ' (deleted)' on + # the end if an executable is updated or deleted + if command.endswith(" (deleted)"): + command = command[:command.rindex(" ")] + if command != self.args[0]: + # Affected executable does not match + return False + except (ValueError, OSError): + # Incorrect PID + return False + return True + + +class ReadFileFilter(CommandFilter): + """Specific filter for the utils.read_file_as_root call""" + + def __init__(self, file_path, *args): + self.file_path = file_path + super(ReadFileFilter, self).__init__("/bin/cat", "root", *args) + + def match(self, userargs): + if userargs[0] != 'cat': + return False + if userargs[1] != self.file_path: + return False + if len(userargs) != 2: + return False + return True diff --git a/moniker/openstack/common/rootwrap/wrapper.py b/moniker/openstack/common/rootwrap/wrapper.py new file mode 100644 index 000000000..9a49246d3 --- /dev/null +++ b/moniker/openstack/common/rootwrap/wrapper.py @@ -0,0 +1,149 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# All Rights Reserved. +# +# 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 ConfigParser +import logging +import logging.handlers +import os +import string + +from moniker.openstack.common.rootwrap import filters + + +class NoFilterMatched(Exception): + """This exception is raised when no filter matched.""" + pass + + +class FilterMatchNotExecutable(Exception): + """ + This exception is raised when a filter matched but no executable was + found. + """ + def __init__(self, match=None, **kwargs): + self.match = match + + +class RootwrapConfig(object): + + def __init__(self, config): + # filters_path + self.filters_path = config.get("DEFAULT", "filters_path").split(",") + + # exec_dirs + if config.has_option("DEFAULT", "exec_dirs"): + self.exec_dirs = config.get("DEFAULT", "exec_dirs").split(",") + else: + # Use system PATH if exec_dirs is not specified + self.exec_dirs = os.environ["PATH"].split(':') + + # syslog_log_facility + if config.has_option("DEFAULT", "syslog_log_facility"): + v = config.get("DEFAULT", "syslog_log_facility") + facility_names = logging.handlers.SysLogHandler.facility_names + self.syslog_log_facility = getattr(logging.handlers.SysLogHandler, + v, None) + if self.syslog_log_facility is None and v in facility_names: + self.syslog_log_facility = facility_names.get(v) + if self.syslog_log_facility is None: + raise ValueError('Unexpected syslog_log_facility: %s' % v) + else: + default_facility = logging.handlers.SysLogHandler.LOG_SYSLOG + self.syslog_log_facility = default_facility + + # syslog_log_level + if config.has_option("DEFAULT", "syslog_log_level"): + v = config.get("DEFAULT", "syslog_log_level") + self.syslog_log_level = logging.getLevelName(v.upper()) + if (self.syslog_log_level == "Level %s" % v.upper()): + raise ValueError('Unexepected syslog_log_level: %s' % v) + else: + self.syslog_log_level = logging.ERROR + + # use_syslog + if config.has_option("DEFAULT", "use_syslog"): + self.use_syslog = config.getboolean("DEFAULT", "use_syslog") + else: + self.use_syslog = False + + +def setup_syslog(execname, facility, level): + rootwrap_logger = logging.getLogger() + rootwrap_logger.setLevel(level) + handler = logging.handlers.SysLogHandler(address='/dev/log', + facility=facility) + handler.setFormatter(logging.Formatter( + os.path.basename(execname) + ': %(message)s')) + rootwrap_logger.addHandler(handler) + + +def build_filter(class_name, *args): + """Returns a filter object of class class_name""" + if not hasattr(filters, class_name): + logging.warning("Skipping unknown filter class (%s) specified " + "in filter definitions" % class_name) + return None + filterclass = getattr(filters, class_name) + return filterclass(*args) + + +def load_filters(filters_path): + """Load filters from a list of directories""" + filterlist = [] + for filterdir in filters_path: + if not os.path.isdir(filterdir): + continue + for filterfile in os.listdir(filterdir): + filterconfig = ConfigParser.RawConfigParser() + filterconfig.read(os.path.join(filterdir, filterfile)) + for (name, value) in filterconfig.items("Filters"): + filterdefinition = [string.strip(s) for s in value.split(',')] + newfilter = build_filter(*filterdefinition) + if newfilter is None: + continue + newfilter.name = name + filterlist.append(newfilter) + return filterlist + + +def match_filter(filters, userargs, exec_dirs=[]): + """ + Checks user command and arguments through command filters and + returns the first matching filter. + Raises NoFilterMatched if no filter matched. + Raises FilterMatchNotExecutable if no executable was found for the + best filter match. + """ + first_not_executable_filter = None + + for f in filters: + if f.match(userargs): + # Try other filters if executable is absent + if not f.get_exec(exec_dirs=exec_dirs): + if not first_not_executable_filter: + first_not_executable_filter = f + continue + # Otherwise return matching filter for execution + return f + + if first_not_executable_filter: + # A filter matched, but no executable was found for it + raise FilterMatchNotExecutable(match=first_not_executable_filter) + + # No filter matched + raise NoFilterMatched() diff --git a/moniker/utils.py b/moniker/utils.py index 334a26597..493825ea4 100644 --- a/moniker/utils.py +++ b/moniker/utils.py @@ -19,12 +19,19 @@ import json from jinja2 import Template from moniker.openstack.common import log as logging from moniker.openstack.common import cfg +from moniker.openstack.common import processutils from moniker.openstack.common.notifier import api as notifier_api from moniker import exceptions LOG = logging.getLogger(__name__) +cfg.CONF.register_opts([ + cfg.StrOpt('root-helper', + default='sudo moniker-rootwrap /etc/moniker/rootwrap.conf') +]) + + def notify(context, service, event_type, payload): priority = 'INFO' publisher_id = notifier_api.publisher_id(service) @@ -112,3 +119,10 @@ def render_template_to_file(template_name, output_path, makedirs=True, with open(output_path, 'w') as output_fh: output_fh.write(content) + + +def execute(*cmd, **kw): + root_helper = kw.pop('root_helper', cfg.CONF.root_helper) + run_as_root = kw.pop('run_as_root', True) + return processutils.execute(*cmd, run_as_root=run_as_root, + root_helper=root_helper, **kw) diff --git a/openstack-common.conf b/openstack-common.conf index f927ee2fd..08448a5fe 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,3 +1,3 @@ [DEFAULT] -modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,service,eventlet_backdoor,network_utils,threadgroup,loopingcall,utils,exception,setup,wsgi,policy,version,uuidutils +modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,service,eventlet_backdoor,network_utils,threadgroup,loopingcall,utils,exception,setup,wsgi,policy,version,uuidutils,processutils,rootwrap base=moniker diff --git a/setup.py b/setup.py index e631a3ae4..26c2a272c 100755 --- a/setup.py +++ b/setup.py @@ -52,7 +52,8 @@ setup( 'bin/moniker-central', 'bin/moniker-api', 'bin/moniker-agent', - 'bin/moniker-manage' + 'bin/moniker-manage', + 'bin/moniker-rootwrap' ], cmdclass=common_setup.get_cmdclass(), entry_points=textwrap.dedent("""