From: Thierry Carrez Date: Tue, 29 Jan 2013 13:58:07 +0000 (+0100) Subject: Import Oslo's common rootwrap to Cinder X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=4275a23af9950ba607d703243e3c6a1592286e93;p=openstack-build%2Fcinder-build.git Import Oslo's common rootwrap to Cinder Import oslo-incubator's rootwrap, which contains all the new features and bugfixes that were pushed to nova-rootwrap earlier in this cycle, including logging support and path search. Implements bp: cinder-common-rootwrap Change-Id: I68cbb788a3dda58dc146933be190146607b48801 --- diff --git a/bin/cinder-rootwrap b/bin/cinder-rootwrap index 2a1f5afea..959c9ff90 100755 --- a/bin/cinder-rootwrap +++ b/bin/cinder-rootwrap @@ -16,24 +16,27 @@ # License for the specific language governing permissions and limitations # under the License. -"""Root wrapper for Cinder +"""Root wrapper for OpenStack services - Filters which commands cinder is allowed to run as another user. + Filters which commands a service is allowed to run as another user. - To use this, you should set the following in cinder.conf: + To use this with cinder, you should set the following in + cinder.conf: rootwrap_config=/etc/cinder/rootwrap.conf - You also need to let the cinder user run cinder-rootwrap as root in sudoers: + You also need to let the cinder user run cinder-rootwrap + as root in sudoers: cinder ALL = (root) NOPASSWD: /usr/bin/cinder-rootwrap - /etc/cinder/rootwrap.conf * + /etc/cinder/rootwrap.conf * - To make allowed commands node-specific, your packaging should only - install volume.filters on volume nodes (i.e. cinder-api nodes should not - have any of those files installed). + 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 @@ -42,6 +45,7 @@ import sys RC_UNAUTHORIZED = 99 RC_NOCOMMAND = 98 RC_BADCONFIG = 97 +RC_NOEXECFOUND = 96 def _subprocess_setup(): @@ -50,45 +54,75 @@ def _subprocess_setup(): 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: - print "%s: %s" % (execname, "No command specified") - sys.exit(RC_NOCOMMAND) + _exit_error(execname, "No command specified", RC_NOCOMMAND, log=False) configfile = sys.argv.pop(0) userargs = sys.argv[:] - # Load configuration - config = ConfigParser.RawConfigParser() - config.read(configfile) - try: - filters_path = config.get("DEFAULT", "filters_path").split(",") - except ConfigParser.Error: - print "%s: Incorrect configuration file: %s" % (execname, configfile) - sys.exit(RC_BADCONFIG) - # 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, "cinder", "__init__.py")): sys.path.insert(0, possible_topdir) - from cinder.rootwrap import wrapper + from cinder.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(filters_path) - filtermatch = wrapper.match_filter(filters, userargs) - if filtermatch: - obj = subprocess.Popen(filtermatch.get_command(userargs), - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - preexec_fn=_subprocess_setup, - env=filtermatch.get_environment(userargs)) - obj.wait() - sys.exit(obj.returncode) - - print "Unauthorized command: %s" % ' '.join(userargs) - sys.exit(RC_UNAUTHORIZED) + 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/cinder/rootwrap/__init__.py b/cinder/openstack/common/rootwrap/__init__.py similarity index 100% rename from cinder/rootwrap/__init__.py rename to cinder/openstack/common/rootwrap/__init__.py diff --git a/cinder/rootwrap/filters.py b/cinder/openstack/common/rootwrap/filters.py similarity index 73% rename from cinder/rootwrap/filters.py rename to cinder/openstack/common/rootwrap/filters.py index 3509602f2..905bbabea 100644 --- a/cinder/rootwrap/filters.py +++ b/cinder/openstack/common/rootwrap/filters.py @@ -15,7 +15,6 @@ # License for the specific language governing permissions and limitations # under the License. - import os import re @@ -24,9 +23,27 @@ 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""" @@ -34,12 +51,13 @@ class CommandFilter(object): return True return False - def get_command(self, userargs): + 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, self.exec_path] + userargs[1:] - return [self.exec_path] + userargs[1:] + 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""" @@ -73,23 +91,33 @@ class RegExpFilter(CommandFilter): class DnsmasqFilter(CommandFilter): """Specific filter for the dnsmasq call (which includes env)""" + CONFIG_FILE_ARG = 'CONFIG_FILE' + def match(self, userargs): - if (userargs[0].startswith("FLAGFILE=") and - userargs[1].startswith("NETWORK_ID=") and - userargs[2] == "dnsmasq"): + 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): - return [self.exec_path] + userargs[3:] + 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['FLAGFILE'] = userargs[0].split('=')[-1] - env['NETWORK_ID'] = userargs[1].split('=')[-1] + 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 diff --git a/cinder/openstack/common/rootwrap/wrapper.py b/cinder/openstack/common/rootwrap/wrapper.py new file mode 100644 index 000000000..06b121e65 --- /dev/null +++ b/cinder/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 cinder.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/cinder/rootwrap/wrapper.py b/cinder/rootwrap/wrapper.py deleted file mode 100644 index 4211a4926..000000000 --- a/cinder/rootwrap/wrapper.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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 os -import string - -from cinder.rootwrap import filters - - -def build_filter(class_name, *args): - """Returns a filter object of class class_name""" - if not hasattr(filters, class_name): - # TODO(ttx): Log the error (whenever cinder-rootwrap has a log file) - 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 - filterlist.append(newfilter) - return filterlist - - -def match_filter(filters, userargs): - """ - Checks user command and arguments through command filters and - returns the first matching filter, or None is none matched. - """ - - found_filter = None - - for f in filters: - if f.match(userargs): - # Try other filters if executable is absent - if not os.access(f.exec_path, os.X_OK): - if not found_filter: - found_filter = f - continue - # Otherwise return matching filter for execution - return f - - # No filter matched or first missing executable - return found_filter diff --git a/cinder/tests/test_cinder_rootwrap.py b/cinder/tests/test_cinder_rootwrap.py deleted file mode 100644 index cf1455105..000000000 --- a/cinder/tests/test_cinder_rootwrap.py +++ /dev/null @@ -1,128 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2011 OpenStack LLC -# -# 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 subprocess - -from cinder.rootwrap import filters -from cinder.rootwrap import wrapper -from cinder import test - - -class RootwrapTestCase(test.TestCase): - - def setUp(self): - super(RootwrapTestCase, self).setUp() - self.filters = [ - filters.RegExpFilter("/bin/ls", "root", 'ls', '/[a-z]+'), - filters.CommandFilter("/usr/bin/foo_bar_not_exist", "root"), - filters.RegExpFilter("/bin/cat", "root", 'cat', '/[a-z]+'), - filters.CommandFilter("/nonexistent/cat", "root"), - filters.CommandFilter("/bin/cat", "root")] # Keep this one last - - def test_RegExpFilter_match(self): - usercmd = ["ls", "/root"] - filtermatch = wrapper.match_filter(self.filters, usercmd) - self.assertFalse(filtermatch is None) - self.assertEqual(filtermatch.get_command(usercmd), - ["/bin/ls", "/root"]) - - def test_RegExpFilter_reject(self): - usercmd = ["ls", "root"] - filtermatch = wrapper.match_filter(self.filters, usercmd) - self.assertTrue(filtermatch is None) - - def test_missing_command(self): - valid_but_missing = ["foo_bar_not_exist"] - invalid = ["foo_bar_not_exist_and_not_matched"] - filtermatch = wrapper.match_filter(self.filters, valid_but_missing) - self.assertTrue(filtermatch is not None) - filtermatch = wrapper.match_filter(self.filters, invalid) - self.assertTrue(filtermatch is None) - - def test_DnsmasqFilter(self): - usercmd = ['FLAGFILE=A', 'NETWORK_ID=foobar', 'dnsmasq', 'foo'] - f = filters.DnsmasqFilter("/usr/bin/dnsmasq", "root") - self.assertTrue(f.match(usercmd)) - self.assertEqual(f.get_command(usercmd), ['/usr/bin/dnsmasq', 'foo']) - env = f.get_environment(usercmd) - self.assertEqual(env.get('FLAGFILE'), 'A') - self.assertEqual(env.get('NETWORK_ID'), 'foobar') - - @test.skip_if(not os.path.exists("/proc/%d" % os.getpid()), - "Test requires /proc filesystem (procfs)") - def test_KillFilter(self): - p = subprocess.Popen(["/bin/sleep", "5"]) - f = filters.KillFilter("root", "/bin/sleep", "-9", "-HUP") - f2 = filters.KillFilter("root", "/usr/bin/sleep", "-9", "-HUP") - usercmd = ['kill', '-ALRM', p.pid] - # Incorrect signal should fail - self.assertFalse(f.match(usercmd) or f2.match(usercmd)) - usercmd = ['kill', p.pid] - # Providing no signal should fail - self.assertFalse(f.match(usercmd) or f2.match(usercmd)) - # Providing matching signal should be allowed - usercmd = ['kill', '-9', p.pid] - self.assertTrue(f.match(usercmd) or f2.match(usercmd)) - - f = filters.KillFilter("root", "/bin/sleep") - f2 = filters.KillFilter("root", "/usr/bin/sleep") - usercmd = ['kill', os.getpid()] - # Our own PID does not match /bin/sleep, so it should fail - self.assertFalse(f.match(usercmd) or f2.match(usercmd)) - usercmd = ['kill', 999999] - # Nonexistent PID should fail - self.assertFalse(f.match(usercmd) or f2.match(usercmd)) - usercmd = ['kill', p.pid] - # Providing no signal should work - self.assertTrue(f.match(usercmd) or f2.match(usercmd)) - - def test_KillFilter_no_raise(self): - """Makes sure ValueError from bug 926412 is gone.""" - f = filters.KillFilter("root", "") - # Providing anything other than kill should be False - usercmd = ['notkill', 999999] - self.assertFalse(f.match(usercmd)) - # Providing something that is not a pid should be False - usercmd = ['kill', 'notapid'] - self.assertFalse(f.match(usercmd)) - - def test_KillFilter_deleted_exe(self): - """Makes sure deleted exe's are killed correctly.""" - # See bug #967931. - def fake_readlink(blah): - return '/bin/commandddddd (deleted)' - - f = filters.KillFilter("root", "/bin/commandddddd") - usercmd = ['kill', 1234] - # Providing no signal should work - self.stubs.Set(os, 'readlink', fake_readlink) - self.assertTrue(f.match(usercmd)) - - def test_ReadFileFilter(self): - goodfn = '/good/file.name' - f = filters.ReadFileFilter(goodfn) - usercmd = ['cat', '/bad/file'] - self.assertFalse(f.match(['cat', '/bad/file'])) - usercmd = ['cat', goodfn] - self.assertEqual(f.get_command(usercmd), ['/bin/cat', goodfn]) - self.assertTrue(f.match(usercmd)) - - def test_skips(self): - # Check that all filters are skipped and that the last matches - usercmd = ["cat", "/"] - filtermatch = wrapper.match_filter(self.filters, usercmd) - self.assertTrue(filtermatch is self.filters[-1]) diff --git a/etc/cinder/rootwrap.conf b/etc/cinder/rootwrap.conf index f2bafe891..dfa8a99cc 100644 --- a/etc/cinder/rootwrap.conf +++ b/etc/cinder/rootwrap.conf @@ -5,3 +5,23 @@ # List of directories to load filter definitions from (separated by ','). # These directories MUST all be only writeable by root ! filters_path=/etc/cinder/rootwrap.d,/usr/share/cinder/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/openstack-common.conf b/openstack-common.conf index d330537e0..f00a3a979 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,7 +1,7 @@ [DEFAULT] # The list of modules to copy from openstack-common -modules=cfg,exception,excutils,gettextutils,importutils,iniparser,jsonutils,local,rpc,timeutils,log,setup,notifier,context,network_utils,policy,uuidutils,lockutils,fileutils,gettextutils,scheduler,scheduler.filters,scheduler.weights,install_venv_common,flakes +modules=cfg,exception,excutils,gettextutils,importutils,iniparser,jsonutils,local,rootwrap,rpc,timeutils,log,setup,notifier,context,network_utils,policy,uuidutils,lockutils,fileutils,gettextutils,scheduler,scheduler.filters,scheduler.weights,install_venv_common,flakes # The base module to hold the copy of openstack.common base=cinder