diff options
Diffstat (limited to 'src/lib/Bcfg2')
-rw-r--r-- | src/lib/Bcfg2/Client/Client.py | 4 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Frame.py | 11 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/POSIX/File.py | 4 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/POSIX/base.py | 12 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/POSIXUsers.py | 300 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/SELinux.py | 121 | ||||
-rw-r--r-- | src/lib/Bcfg2/Client/Tools/__init__.py | 1 | ||||
-rw-r--r-- | src/lib/Bcfg2/Compat.py | 15 | ||||
-rw-r--r-- | src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html | 2 | ||||
-rw-r--r-- | src/lib/Bcfg2/Reporting/views.py | 2 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py | 65 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py | 5 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/FileProbes.py | 6 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/SEModules.py | 6 | ||||
-rw-r--r-- | src/lib/Bcfg2/Server/Plugins/SSLCA.py | 491 | ||||
-rw-r--r-- | src/lib/Bcfg2/version.py | 4 |
16 files changed, 731 insertions, 318 deletions
diff --git a/src/lib/Bcfg2/Client/Client.py b/src/lib/Bcfg2/Client/Client.py index f197a9074..45e0b64e6 100644 --- a/src/lib/Bcfg2/Client/Client.py +++ b/src/lib/Bcfg2/Client/Client.py @@ -56,8 +56,8 @@ class Client(object): self.logger.error("Service removal is nonsensical; " "removed services will only be disabled") if (self.setup['remove'] and - self.setup['remove'].lower() not in ['all', 'services', - 'packages']): + self.setup['remove'].lower() not in ['all', 'services', 'packages', + 'users']): self.logger.error("Got unknown argument %s for -r" % self.setup['remove']) if self.setup["file"] and self.setup["cache"]: diff --git a/src/lib/Bcfg2/Client/Frame.py b/src/lib/Bcfg2/Client/Frame.py index 53180ab68..4f3ff1820 100644 --- a/src/lib/Bcfg2/Client/Frame.py +++ b/src/lib/Bcfg2/Client/Frame.py @@ -105,6 +105,10 @@ class Frame(object): if deprecated: self.logger.warning("Loaded deprecated tool drivers:") self.logger.warning(deprecated) + experimental = [tool.name for tool in self.tools if tool.experimental] + if experimental: + self.logger.warning("Loaded experimental tool drivers:") + self.logger.warning(experimental) # find entries not handled by any tools self.unhandled = [entry for struct in config @@ -281,12 +285,15 @@ class Frame(object): if self.setup['remove']: if self.setup['remove'] == 'all': self.removal = self.extra - elif self.setup['remove'] in ['services', 'Services']: + elif self.setup['remove'].lower() == 'services': self.removal = [entry for entry in self.extra if entry.tag == 'Service'] - elif self.setup['remove'] in ['packages', 'Packages']: + elif self.setup['remove'].lower() == 'packages': self.removal = [entry for entry in self.extra if entry.tag == 'Package'] + elif self.setup['remove'].lower() == 'users': + self.removal = [entry for entry in self.extra + if entry.tag in ['POSIXUser', 'POSIXGroup']] candidates = [entry for entry in self.states if not self.states[entry]] diff --git a/src/lib/Bcfg2/Client/Tools/POSIX/File.py b/src/lib/Bcfg2/Client/Tools/POSIX/File.py index 5842c4e1f..9b95d2234 100644 --- a/src/lib/Bcfg2/Client/Tools/POSIX/File.py +++ b/src/lib/Bcfg2/Client/Tools/POSIX/File.py @@ -188,6 +188,10 @@ class POSIXFile(POSIXTool): prompt.append(udiff) except UnicodeEncodeError: prompt.append("Could not encode diff") + elif entry.get("empty", "true"): + # the file doesn't exist on disk, but there's no + # expected content + prompt.append("%s does not exist" % entry.get("name")) else: prompt.append("Diff took too long to compute, no " "printable diff") diff --git a/src/lib/Bcfg2/Client/Tools/POSIX/base.py b/src/lib/Bcfg2/Client/Tools/POSIX/base.py index 6388f6731..b867fa3d8 100644 --- a/src/lib/Bcfg2/Client/Tools/POSIX/base.py +++ b/src/lib/Bcfg2/Client/Tools/POSIX/base.py @@ -9,6 +9,7 @@ import copy import shutil import Bcfg2.Client.Tools import Bcfg2.Client.XML +from Bcfg2.Compat import oct_mode try: import selinux @@ -128,7 +129,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): wanted_mode |= device_map[entry.get('dev_type')] try: self.logger.debug("POSIX: Setting mode on %s to %s" % - (path, oct(wanted_mode))) + (path, oct_mode(wanted_mode))) os.chmod(path, wanted_mode) except (OSError, KeyError): self.logger.error('POSIX: Failed to change mode on %s' % @@ -436,7 +437,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): group = None try: - mode = oct(ondisk[stat.ST_MODE])[-4:] + mode = oct_mode(ondisk[stat.ST_MODE])[-4:] except (OSError, KeyError, TypeError): err = sys.exc_info()[1] self.logger.debug("POSIX: Could not get current permissions of " @@ -507,7 +508,7 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): (path, attrib['current_group'], entry.get('group'))) if (wanted_mode and - oct(int(attrib['current_mode'], 8)) != oct(wanted_mode)): + oct_mode(int(attrib['current_mode'], 8)) != oct_mode(wanted_mode)): errors.append("Permissions for path %s are incorrect. " "Current permissions are %s but should be %s" % (path, attrib['current_mode'], entry.get('mode'))) @@ -708,10 +709,11 @@ class POSIXTool(Bcfg2.Client.Tools.Tool): for i in range(0, 3): if newmode & (6 * pow(8, i)): newmode |= 1 * pow(8, i) - tmpentry.set('mode', oct(newmode)) + tmpentry.set('mode', oct_mode(newmode)) for acl in tmpentry.findall('ACL'): acl.set('perms', - oct(self._norm_acl_perms(acl.get('perms')) | ACL_MAP['x'])) + oct_mode(self._norm_acl_perms(acl.get('perms')) | \ + ACL_MAP['x'])) for cpath in created: rv &= self._set_perms(tmpentry, path=cpath) return rv diff --git a/src/lib/Bcfg2/Client/Tools/POSIXUsers.py b/src/lib/Bcfg2/Client/Tools/POSIXUsers.py new file mode 100644 index 000000000..78734f5c2 --- /dev/null +++ b/src/lib/Bcfg2/Client/Tools/POSIXUsers.py @@ -0,0 +1,300 @@ +""" A tool to handle creating users and groups with useradd/mod/del +and groupadd/mod/del """ + +import sys +import pwd +import grp +import Bcfg2.Client.XML +import subprocess +import Bcfg2.Client.Tools + + +class ExecutionError(Exception): + """ Raised when running an external command fails """ + + def __init__(self, msg, retval=None): + Exception.__init__(self, msg) + self.retval = retval + + def __str__(self): + return "%s (rv: %s)" % (Exception.__str__(self), + self.retval) + + +class Executor(object): + """ A better version of Bcfg2.Client.Tool.Executor, which captures + stderr, raises exceptions on error, and doesn't use the shell to + execute by default """ + + def __init__(self, logger): + self.logger = logger + self.stdout = None + self.stderr = None + self.retval = None + + def run(self, command, inputdata=None, shell=False): + """ Run a command, given as a list, optionally giving it the + specified input data """ + self.logger.debug("Running: %s" % " ".join(command)) + proc = subprocess.Popen(command, shell=shell, bufsize=16384, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + if inputdata: + for line in inputdata.splitlines(): + self.logger.debug('> %s' % line) + (self.stdout, self.stderr) = proc.communicate(inputdata) + else: + (self.stdout, self.stderr) = proc.communicate() + for line in self.stdout.splitlines(): # pylint: disable=E1103 + self.logger.debug('< %s' % line) + self.retval = proc.wait() + if self.retval == 0: + for line in self.stderr.splitlines(): # pylint: disable=E1103 + self.logger.warning(line) + return True + else: + raise ExecutionError(self.stderr, self.retval) + + +class POSIXUsers(Bcfg2.Client.Tools.Tool): + """ A tool to handle creating users and groups with + useradd/mod/del and groupadd/mod/del """ + __execs__ = ['/usr/sbin/useradd', '/usr/sbin/usermod', '/usr/sbin/userdel', + '/usr/sbin/groupadd', '/usr/sbin/groupmod', + '/usr/sbin/groupdel'] + __handles__ = [('POSIXUser', None), + ('POSIXGroup', None)] + __req__ = dict(POSIXUser=['name'], + POSIXGroup=['name']) + experimental = True + + # A mapping of XML entry attributes to the indexes of + # corresponding values in the get*ent data structures + attr_mapping = dict(POSIXUser=dict(name=0, uid=2, gecos=4, home=5, + shell=6), + POSIXGroup=dict(name=0, gid=2)) + + def __init__(self, logger, setup, config): + Bcfg2.Client.Tools.Tool.__init__(self, logger, setup, config) + self.set_defaults = dict(POSIXUser=self.populate_user_entry, + POSIXGroup=lambda g: g) + self.cmd = Executor(logger) + self._existing = None + + @property + def existing(self): + """ Get a dict of existing users and groups """ + if self._existing is None: + self._existing = dict(POSIXUser=dict([(u[0], u) + for u in pwd.getpwall()]), + POSIXGroup=dict([(g[0], g) + for g in grp.getgrall()])) + return self._existing + + def Inventory(self, states, structures=None): + if not structures: + structures = self.config.getchildren() + # we calculate a list of all POSIXUser and POSIXGroup entries, + # and then add POSIXGroup entries that are required to create + # the primary group for each user to the structures. this is + # sneaky and possibly evil, but it works great. + groups = [] + for struct in structures: + groups.extend([e.get("name") + for e in struct.findall("POSIXGroup")]) + for struct in structures: + for entry in struct.findall("POSIXUser"): + group = self.set_defaults[entry.tag](entry).get('group') + if group and group not in groups: + self.logger.debug("POSIXUsers: Adding POSIXGroup entry " + "'%s' for user '%s'" % + (group, entry.get("name"))) + struct.append(Bcfg2.Client.XML.Element("POSIXGroup", + name=group)) + return Bcfg2.Client.Tools.Tool.Inventory(self, states, structures) + + def FindExtra(self): + extra = [] + for handles in self.__handles__: + tag = handles[0] + specified = [] + for entry in self.getSupportedEntries(): + if entry.tag == tag: + specified.append(entry.get("name")) + extra.extend([Bcfg2.Client.XML.Element(tag, name=e) + for e in self.existing[tag].keys() + if e not in specified]) + return extra + + def populate_user_entry(self, entry): + """ Given a POSIXUser entry, set all of the 'missing' attributes + with their defaults """ + defaults = dict(group=entry.get('name'), + gecos=entry.get('name'), + shell='/bin/bash') + if entry.get('name') == 'root': + defaults['home'] = '/root' + else: + defaults['home'] = '/home/%s' % entry.get('name') + for key, val in defaults.items(): + if entry.get(key) is None: + entry.set(key, val) + if entry.get('group') in self.existing['POSIXGroup']: + entry.set('gid', + str(self.existing['POSIXGroup'][entry.get('group')][2])) + return entry + + def user_supplementary_groups(self, entry): + """ Get a list of supplmentary groups that the user in the + given entry is a member of """ + return [g for g in self.existing['POSIXGroup'].values() + if entry.get("name") in g[3] and g[0] != entry.get("group")] + + def VerifyPOSIXUser(self, entry, _): + """ Verify a POSIXUser entry """ + rv = self._verify(self.populate_user_entry(entry)) + if entry.get("current_exists", "true") == "true": + # verify supplemental groups + actual = [g[0] for g in self.user_supplementary_groups(entry)] + expected = [e.text for e in entry.findall("MemberOf")] + if set(expected) != set(actual): + entry.set('qtext', + "\n".join([entry.get('qtext', '')] + + ["%s %s has incorrect supplemental group " + "membership. Currently: %s. Should be: %s" + % (entry.tag, entry.get("name"), + actual, expected)])) + rv = False + if self.setup['interactive'] and not rv: + entry.set('qtext', + '%s\nInstall %s %s: (y/N) ' % + (entry.get('qtext', ''), entry.tag, entry.get('name'))) + return rv + + def VerifyPOSIXGroup(self, entry, _): + """ Verify a POSIXGroup entry """ + rv = self._verify(entry) + if self.setup['interactive'] and not rv: + entry.set('qtext', + '%s\nInstall %s %s: (y/N) ' % + (entry.get('qtext', ''), entry.tag, entry.get('name'))) + return rv + + def _verify(self, entry): + """ Perform most of the actual work of verification """ + errors = [] + if entry.get("name") not in self.existing[entry.tag]: + entry.set('current_exists', 'false') + errors.append("%s %s does not exist" % (entry.tag, + entry.get("name"))) + else: + for attr, idx in self.attr_mapping[entry.tag].items(): + val = str(self.existing[entry.tag][entry.get("name")][idx]) + entry.set("current_%s" % attr, val) + if attr in ["uid", "gid"]: + if entry.get(attr) is None: + # no uid/gid specified, so we let the tool + # automatically determine one -- i.e., it always + # verifies + continue + if val != entry.get(attr): + errors.append("%s for %s %s is incorrect. Current %s is " + "%s, but should be %s" % + (attr.title(), entry.tag, entry.get("name"), + attr, entry.get(attr), val)) + + if errors: + for error in errors: + self.logger.debug("%s: %s" % (self.name, error)) + entry.set('qtext', "\n".join([entry.get('qtext', '')] + errors)) + return len(errors) == 0 + + def Install(self, entries, states): + for entry in entries: + # install groups first, so that all groups exist for + # users that might need them + if entry.tag == 'POSIXGroup': + states[entry] = self._install(entry) + for entry in entries: + if entry.tag == 'POSIXUser': + states[entry] = self._install(entry) + self._existing = None + + def _install(self, entry): + """ add or modify a user or group using the appropriate command """ + if entry.get("name") not in self.existing[entry.tag]: + action = "add" + else: + action = "mod" + try: + self.cmd.run(self._get_cmd(action, + self.set_defaults[entry.tag](entry))) + self.modified.append(entry) + return True + except ExecutionError: + self.logger.error("POSIXUsers: Error creating %s %s: %s" % + (entry.tag, entry.get("name"), + sys.exc_info()[1])) + return False + + def _get_cmd(self, action, entry): + """ Get a command to perform the appropriate action (add, mod, + del) on the given entry. The command is always the same; we + set all attributes on a given user or group when modifying it + rather than checking which ones need to be changed. This + makes things fail as a unit (e.g., if a user is logged in, you + can't change its home dir, but you could change its GECOS, but + the whole operation fails), but it also makes this function a + lot, lot easier and simpler.""" + cmd = ["/usr/sbin/%s%s" % (entry.tag[5:].lower(), action)] + if action != 'del': + if entry.tag == 'POSIXGroup': + if entry.get('gid'): + cmd.extend(['-g', entry.get('gid')]) + elif entry.tag == 'POSIXUser': + cmd.append('-m') + if entry.get('uid'): + cmd.extend(['-u', entry.get('uid')]) + cmd.extend(['-g', entry.get('group')]) + extras = [e.text for e in entry.findall("MemberOf")] + if extras: + cmd.extend(['-G', ",".join(extras)]) + cmd.extend(['-d', entry.get('home')]) + cmd.extend(['-s', entry.get('shell')]) + cmd.extend(['-c', entry.get('gecos')]) + cmd.append(entry.get('name')) + return cmd + + def Remove(self, entries): + for entry in entries: + # remove users first, so that all users have been removed + # from groups before we remove them + if entry.tag == 'POSIXUser': + self._remove(entry) + for entry in entries: + if entry.tag == 'POSIXGroup': + try: + grp.getgrnam(entry.get("name")) + self._remove(entry) + except KeyError: + # at least some versions of userdel automatically + # remove the primary group for a user if the group + # name is the same as the username, and no other + # users are in the group + self.logger.info("POSIXUsers: Group %s does not exist. " + "It may have already been removed when " + "its users were deleted" % + entry.get("name")) + self._existing = None + self.extra = self.FindExtra() + + def _remove(self, entry): + """ Remove an entry """ + try: + self.cmd.run(self._get_cmd("del", entry)) + return True + except ExecutionError: + self.logger.error("POSIXUsers: Error deleting %s %s: %s" % + (entry.tag, entry.get("name"), + sys.exc_info()[1])) + return False diff --git a/src/lib/Bcfg2/Client/Tools/SELinux.py b/src/lib/Bcfg2/Client/Tools/SELinux.py index fc47883c9..6bd728114 100644 --- a/src/lib/Bcfg2/Client/Tools/SELinux.py +++ b/src/lib/Bcfg2/Client/Tools/SELinux.py @@ -58,36 +58,48 @@ def netmask_itoa(netmask, proto="ipv4"): class SELinux(Bcfg2.Client.Tools.Tool): """ SELinux entry support """ name = 'SELinux' - __handles__ = [('SELinux', 'boolean'), - ('SELinux', 'port'), - ('SELinux', 'fcontext'), - ('SELinux', 'node'), - ('SELinux', 'login'), - ('SELinux', 'user'), - ('SELinux', 'interface'), - ('SELinux', 'permissive'), - ('SELinux', 'module')] - __req__ = dict(SELinux=dict(boolean=['name', 'value'], - module=['name'], - port=['name', 'selinuxtype'], - fcontext=['name', 'selinuxtype'], - node=['name', 'selinuxtype', 'proto'], - login=['name', 'selinuxuser'], - user=['name', 'roles', 'prefix'], - interface=['name', 'selinuxtype'], - permissive=['name'])) + __handles__ = [('SEBoolean', None), + ('SEFcontext', None), + ('SEInterface', None), + ('SELogin', None), + ('SEModule', None), + ('SENode', None), + ('SEPermissive', None), + ('SEPort', None), + ('SEUser', None)] + __req__ = dict(SEBoolean=['name', 'value'], + SEFcontext=['name', 'selinuxtype'], + SEInterface=['name', 'selinuxtype'], + SELogin=['name', 'selinuxuser'], + SEModule=['name'], + SENode=['name', 'selinuxtype', 'proto'], + SEPermissive=['name'], + SEPort=['name', 'selinuxtype'], + SEUser=['name', 'roles', 'prefix']) def __init__(self, logger, setup, config): Bcfg2.Client.Tools.Tool.__init__(self, logger, setup, config) self.handlers = {} - for handles in self.__handles__: - etype = handles[1] + for handler in self.__handles__: + etype = handler[0] self.handlers[etype] = \ globals()["SELinux%sHandler" % etype.title()](self, logger, setup, config) self.txn = False self.post_txn_queue = [] + def __getattr__(self, attr): + if attr.startswith("VerifySE"): + return self.GenericSEVerify + elif attr.startswith("InstallSE"): + return self.GenericSEInstall + # there's no need for an else here, because python checks for + # an attribute in the "normal" ways first. i.e., if self.txn + # is used, __getattr__() is never called because txn exists as + # a "normal" attribute of this object. See + # http://docs.python.org/2/reference/datamodel.html#object.__getattr__ + # for details + def BundleUpdated(self, _, states): for handler in self.handlers.values(): handler.BundleUpdated(states) @@ -100,12 +112,12 @@ class SELinux(Bcfg2.Client.Tools.Tool): def canInstall(self, entry): return (Bcfg2.Client.Tools.Tool.canInstall(self, entry) and - self.handlers[entry.get('type')].canInstall(entry)) + self.handlers[entry.tag].canInstall(entry)) def primarykey(self, entry): """ return a string that should be unique amongst all entries in the specification """ - return self.handlers[entry.get('type')].primarykey(entry) + return self.handlers[entry.tag].primarykey(entry) def Install(self, entries, states): # start a transaction @@ -125,32 +137,32 @@ class SELinux(Bcfg2.Client.Tools.Tool): for func, arg, kwargs in self.post_txn_queue: states[arg] = func(*arg, **kwargs) - def InstallSELinux(self, entry): - """Dispatch install to the proper method according to type""" - return self.handlers[entry.get('type')].Install(entry) + def GenericSEInstall(self, entry): + """Dispatch install to the proper method according to entry tag""" + return self.handlers[entry.tag].Install(entry) - def VerifySELinux(self, entry, _): - """Dispatch verify to the proper method according to type""" - rv = self.handlers[entry.get('type')].Verify(entry) + def GenericSEVerify(self, entry, _): + """Dispatch verify to the proper method according to entry tag""" + rv = self.handlers[entry.tag].Verify(entry) if entry.get('qtext') and self.setup['interactive']: entry.set('qtext', - '%s\nInstall SELinux %s %s: (y/N) ' % + '%s\nInstall %s: (y/N) ' % (entry.get('qtext'), - entry.get('type'), - self.handlers[entry.get('type')].tostring(entry))) + self.handlers[entry.tag].tostring(entry))) return rv def Remove(self, entries): - """Dispatch verify to the proper removal method according to type""" + """Dispatch verify to the proper removal + method according to entry tag""" # sort by type types = list() for entry in entries: - if entry.get('type') not in types: - types.append(entry.get('type')) + if entry.tag not in types: + types.append(entry.tag) for etype in types: self.handlers[etype].Remove([e for e in entries - if e.get('type') == etype]) + if e.tag == etype]) class SELinuxEntryHandler(object): @@ -253,8 +265,7 @@ class SELinuxEntryHandler(object): def key2entry(self, key): """ Generate an XML entry from an SELinux record key """ attrs = self._key2attrs(key) - attrs["type"] = self.etype - return Bcfg2.Client.XML.Element("SELinux", **attrs) + return Bcfg2.Client.XML.Element(self.etype, **attrs) def _args(self, entry, method): """ Get the argument list for invoking _modify or _add, or @@ -279,7 +290,7 @@ class SELinuxEntryHandler(object): """ return a string that should be unique amongst all entries in the specification. some entry types are not universally disambiguated by tag:type:name alone """ - return ":".join([entry.tag, entry.get("type"), entry.get("name")]) + return ":".join([entry.tag, entry.get("name")]) def exists(self, entry): """ return True if the entry already exists in the record list """ @@ -303,8 +314,8 @@ class SELinuxEntryHandler(object): continue if current_attrs[attr] != desired_attrs[attr]: entry.set('current_%s' % attr, current_attrs[attr]) - errors.append("SELinux %s %s has wrong %s: %s, should be %s" % - (self.etype, self.tostring(entry), attr, + errors.append("%s %s has wrong %s: %s, should be %s" % + (entry.tag, entry.get('name'), attr, current_attrs[attr], desired_attrs[attr])) if errors: @@ -331,8 +342,8 @@ class SELinuxEntryHandler(object): return True except ValueError: err = sys.exc_info()[1] - self.logger.debug("Failed to %s SELinux %s %s: %s" % - (method, self.etype, self.tostring(entry), err)) + self.logger.info("Failed to %s SELinux %s %s: %s" % + (method, self.etype, self.tostring(entry), err)) return False def Remove(self, entries): @@ -365,7 +376,7 @@ class SELinuxEntryHandler(object): pass -class SELinuxBooleanHandler(SELinuxEntryHandler): +class SELinuxSebooleanHandler(SELinuxEntryHandler): """ handle SELinux boolean entries """ etype = "boolean" value_format = ("value",) @@ -414,7 +425,7 @@ class SELinuxBooleanHandler(SELinuxEntryHandler): SELinuxEntryHandler.canInstall(self, entry)) -class SELinuxPortHandler(SELinuxEntryHandler): +class SELinuxSeportHandler(SELinuxEntryHandler): """ handle SELinux port entries """ etype = "port" value_format = ('selinuxtype', None) @@ -486,7 +497,7 @@ class SELinuxPortHandler(SELinuxEntryHandler): return tuple(entry.get("name").split("/")) -class SELinuxFcontextHandler(SELinuxEntryHandler): +class SELinuxSefcontextHandler(SELinuxEntryHandler): """ handle SELinux file context entries """ etype = "fcontext" @@ -556,11 +567,11 @@ class SELinuxFcontextHandler(SELinuxEntryHandler): '', '') def primarykey(self, entry): - return ":".join([entry.tag, entry.get("type"), entry.get("name"), + return ":".join([entry.tag, entry.get("name"), entry.get("filetype", "all")]) -class SELinuxNodeHandler(SELinuxEntryHandler): +class SELinuxSenodeHandler(SELinuxEntryHandler): """ handle SELinux node entries """ etype = "node" @@ -592,7 +603,7 @@ class SELinuxNodeHandler(SELinuxEntryHandler): entry.get("selinuxtype")) -class SELinuxLoginHandler(SELinuxEntryHandler): +class SELinuxSeloginHandler(SELinuxEntryHandler): """ handle SELinux login entries """ etype = "login" @@ -603,7 +614,7 @@ class SELinuxLoginHandler(SELinuxEntryHandler): return (entry.get("name"), entry.get("selinuxuser"), "") -class SELinuxUserHandler(SELinuxEntryHandler): +class SELinuxSeuserHandler(SELinuxEntryHandler): """ handle SELinux user entries """ etype = "user" @@ -652,7 +663,7 @@ class SELinuxUserHandler(SELinuxEntryHandler): return tuple(rv) -class SELinuxInterfaceHandler(SELinuxEntryHandler): +class SELinuxSeinterfaceHandler(SELinuxEntryHandler): """ handle SELinux interface entries """ etype = "interface" @@ -663,7 +674,7 @@ class SELinuxInterfaceHandler(SELinuxEntryHandler): return (entry.get("name"), '', entry.get("selinuxtype")) -class SELinuxPermissiveHandler(SELinuxEntryHandler): +class SELinuxSepermissiveHandler(SELinuxEntryHandler): """ handle SELinux permissive domain entries """ etype = "permissive" @@ -695,7 +706,7 @@ class SELinuxPermissiveHandler(SELinuxEntryHandler): return (entry.get("name"),) -class SELinuxModuleHandler(SELinuxEntryHandler): +class SELinuxSemoduleHandler(SELinuxEntryHandler): """ handle SELinux module entries """ etype = "module" @@ -808,10 +819,9 @@ class SELinuxModuleHandler(SELinuxEntryHandler): def Install(self, entry, _=None): if not self.filetool.install(self._pathentry(entry)): return False - if hasattr(self, 'records'): + if hasattr(seobject, 'moduleRecords'): # if seobject has the moduleRecords attribute, install the # module using the seobject library - self.records # pylint: disable=W0104 return self._install_seobject(entry) else: # seobject doesn't have the moduleRecords attribute, so @@ -891,8 +901,7 @@ class SELinuxModuleHandler(SELinuxEntryHandler): def FindExtra(self): specified = [self._key(e) - for e in self.tool.getSupportedEntries() - if e.get("type") == self.etype] + for e in self.tool.getSupportedEntries()] rv = [] for module in self._all_records_from_filesystem().keys(): if module not in specified: diff --git a/src/lib/Bcfg2/Client/Tools/__init__.py b/src/lib/Bcfg2/Client/Tools/__init__.py index 927b25ba8..d5f55759f 100644 --- a/src/lib/Bcfg2/Client/Tools/__init__.py +++ b/src/lib/Bcfg2/Client/Tools/__init__.py @@ -61,6 +61,7 @@ class Tool(object): __req__ = {} __important__ = [] deprecated = False + experimental = False def __init__(self, logger, setup, config): self.setup = setup diff --git a/src/lib/Bcfg2/Compat.py b/src/lib/Bcfg2/Compat.py index 23f7ef784..b0f0ef5cf 100644 --- a/src/lib/Bcfg2/Compat.py +++ b/src/lib/Bcfg2/Compat.py @@ -245,3 +245,18 @@ except ImportError: def wraps(wrapped): # pylint: disable=W0613 """ implementation of functools.wraps() for python 2.4 """ return lambda f: f + + +def oct_mode(mode): + """ Convert a decimal number describing a POSIX permissions mode + to a string giving the octal mode. In Python 2, this is a synonym + for :func:`oct`, but in Python 3 the octal format has changed to + ``0o000``, which cannot be used as an octal permissions mode, so + we need to strip the 'o' from the output. I.e., this function + acts like the Python 2 :func:`oct` regardless of what version of + Python is in use. + + :param mode: The decimal mode to convert to octal + :type mode: int + :returns: string """ + return oct(mode).replace('o', '') diff --git a/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html b/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html index 06c99d899..fd9a545ce 100644 --- a/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html +++ b/src/lib/Bcfg2/Reporting/templates/clients/detailed-list.html @@ -5,8 +5,8 @@ {% block pagebanner %}Clients - Detailed View{% endblock %} {% block content %} -<div class='client_list_box'> {% filter_navigator %} +<div class='client_list_box'> {% if entry_list %} <table cellpadding="3"> <tr id='table_list_header' class='listview'> diff --git a/src/lib/Bcfg2/Reporting/views.py b/src/lib/Bcfg2/Reporting/views.py index 7dc216bd4..8ab3f8e59 100644 --- a/src/lib/Bcfg2/Reporting/views.py +++ b/src/lib/Bcfg2/Reporting/views.py @@ -276,7 +276,7 @@ def client_index(request, timestamp=None, **kwargs): """ list = _handle_filters(Interaction.objects.recent(timestamp), **kwargs).\ - select_related().order_by("client__name").all() + select_related('client').order_by("client__name").all() return render_to_response('clients/index.html', {'inter_list': list, diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py index 3a78b4847..73550cd9d 100644 --- a/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/CfgGenshiGenerator.py @@ -11,8 +11,39 @@ from Bcfg2.Server.Plugins.Cfg import CfgGenerator, SETUP try: import genshi.core from genshi.template import TemplateLoader, NewTextTemplate - from genshi.template.eval import UndefinedError + from genshi.template.eval import UndefinedError, Suite + #: True if Genshi libraries are available HAS_GENSHI = True + + def _genshi_removes_blank_lines(): + """ Genshi 0.5 uses the Python :mod:`compiler` package to + compile genshi snippets to AST. Genshi 0.6 uses some bespoke + magic, because compiler has been deprecated. + :func:`compiler.parse` produces an AST that removes all excess + whitespace (e.g., blank lines), while + :func:`genshi.template.astutil.parse` does not. In order to + determine which actual line of code an error occurs on, we + need to know which is in use and how it treats blank lines. + I've beat my head against this for hours and the best/only way + I can find is to compile some genshi code with an error and + see which line it's on.""" + code = """d = dict() + +d['a']""" + try: + Suite(code).execute(dict()) + except KeyError: + line = traceback.extract_tb(sys.exc_info()[2])[-1][1] + if line == 2: + return True + else: + return False + + #: True if Genshi removes all blank lines from a code block before + #: executing it; False indicates that Genshi only removes leading + #: and trailing blank lines. See + #: :func:`_genshi_removes_blank_lines` for an explanation of this. + GENSHI_REMOVES_BLANK_LINES = _genshi_removes_blank_lines() except ImportError: TemplateLoader = None # pylint: disable=C0103 HAS_GENSHI = False @@ -111,7 +142,17 @@ class CfgGenshiGenerator(CfgGenerator): # the traceback is just the beginning of the block. err = exc[1] stack = traceback.extract_tb(exc[2]) - lineno, func = stack[-1][1:3] + + # find the right frame of the stack + for frame in reversed(stack): + if frame[0] == self.name: + lineno, func = frame[1:3] + break + else: + # couldn't even find the stack frame, wtf. + raise PluginExecutionError("%s: %s" % + (err.__class__.__name__, err)) + execs = [contents for etype, contents, _ in self.template.stream if etype == self.template.EXEC] @@ -129,18 +170,20 @@ class CfgGenshiGenerator(CfgGenerator): # else, no EXEC blocks -- WTF? if contents: # we now have the bogus block, but we need to get the - # offending line. To get there, we do (line number - # given in the exception) - (firstlineno from the - # internal genshi code object of the snippet) + 1 = - # (line number of the line with an error within the - # block, with all multiple line breaks elided to a - # single line break) - real_lineno = lineno - contents.code.co_firstlineno - src = re.sub(r'\n\n+', '\n', contents.source).splitlines() + # offending line. To get there, we do (line number given + # in the exception) - (firstlineno from the internal + # genshi code object of the snippet) = (line number of the + # line with an error within the block, with blank lines + # removed as appropriate for + # :attr:`GENSHI_REMOVES_BLANK_LINES`) + code = contents.source.strip().splitlines() + if GENSHI_REMOVES_BLANK_LINES: + code = [l for l in code if l.strip()] try: + line = code[lineno - contents.code.co_firstlineno] raise PluginExecutionError("%s: %s at '%s'" % (err.__class__.__name__, err, - src[real_lineno])) + line)) except IndexError: raise PluginExecutionError("%s: %s" % (err.__class__.__name__, err)) diff --git a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py index db6810e7c..f8712213e 100644 --- a/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py +++ b/src/lib/Bcfg2/Server/Plugins/Cfg/__init__.py @@ -11,7 +11,8 @@ import Bcfg2.Options import Bcfg2.Server.Plugin import Bcfg2.Server.Lint # pylint: disable=W0622 -from Bcfg2.Compat import u_str, unicode, b64encode, walk_packages, any +from Bcfg2.Compat import u_str, unicode, b64encode, walk_packages, any, \ + oct_mode # pylint: enable=W0622 LOGGER = logging.getLogger(__name__) @@ -538,7 +539,7 @@ class CfgEntrySet(Bcfg2.Server.Plugin.EntrySet): entry.get("name")) fname = os.path.join(self.path, generator.name) entry.set('mode', - str(oct(stat.S_IMODE(os.stat(fname).st_mode)))) + oct_mode(stat.S_IMODE(os.stat(fname).st_mode))) try: return generator.get_data(entry, metadata) except: diff --git a/src/lib/Bcfg2/Server/Plugins/FileProbes.py b/src/lib/Bcfg2/Server/Plugins/FileProbes.py index 8bd1d3504..5ec0d7280 100644 --- a/src/lib/Bcfg2/Server/Plugins/FileProbes.py +++ b/src/lib/Bcfg2/Server/Plugins/FileProbes.py @@ -24,7 +24,7 @@ import sys import pwd import grp import Bcfg2.Client.XML -from Bcfg2.Compat import b64encode +from Bcfg2.Compat import b64encode, oct_mode path = "%s" @@ -41,7 +41,7 @@ data = Bcfg2.Client.XML.Element("ProbedFileData", name=path, owner=pwd.getpwuid(stat[4])[0], group=grp.getgrgid(stat[5])[0], - mode=oct(stat[0] & 4095)) + mode=oct_mode(stat[0] & 4095)) try: data.text = b64encode(open(path).read()) except: @@ -101,7 +101,7 @@ class FileProbes(Bcfg2.Server.Plugin.Plugin, for data in datalist: if data.text is None: - self.logger.error("Got null response to %s file probe from %s" + self.logger.error("Got null response to %s file probe from %s" % (data.get('name'), metadata.hostname)) else: try: diff --git a/src/lib/Bcfg2/Server/Plugins/SEModules.py b/src/lib/Bcfg2/Server/Plugins/SEModules.py index 3edfb72a3..fa47f9496 100644 --- a/src/lib/Bcfg2/Server/Plugins/SEModules.py +++ b/src/lib/Bcfg2/Server/Plugins/SEModules.py @@ -40,8 +40,8 @@ class SEModules(Bcfg2.Server.Plugin.GroupSpool): #: objects as its EntrySet children. es_child_cls = SEModuleData - #: SEModules manages ``SELinux`` entries - entry_type = 'SELinux' + #: SEModules manages ``SEModule`` entries + entry_type = 'SEModule' #: The SEModules plugin is experimental experimental = True @@ -68,7 +68,7 @@ class SEModules(Bcfg2.Server.Plugin.GroupSpool): return name.lstrip("/") def HandlesEntry(self, entry, metadata): - if entry.tag in self.Entries and entry.get('type') == 'module': + if entry.tag in self.Entries: return self._get_module_filename(entry) in self.Entries[entry.tag] return Bcfg2.Server.Plugin.GroupSpool.HandlesEntry(self, entry, metadata) diff --git a/src/lib/Bcfg2/Server/Plugins/SSLCA.py b/src/lib/Bcfg2/Server/Plugins/SSLCA.py index b3a49c047..f83c04e87 100644 --- a/src/lib/Bcfg2/Server/Plugins/SSLCA.py +++ b/src/lib/Bcfg2/Server/Plugins/SSLCA.py @@ -3,253 +3,164 @@ certificates and their keys. """ import os import sys -import Bcfg2.Server.Plugin -import Bcfg2.Options -import lxml.etree +import logging import tempfile +import lxml.etree from subprocess import Popen, PIPE, STDOUT -from Bcfg2.Compat import ConfigParser, md5 +import Bcfg2.Options +import Bcfg2.Server.Plugin +from Bcfg2.Compat import ConfigParser from Bcfg2.Server.Plugin import PluginExecutionError +LOGGER = logging.getLogger(__name__) -class SSLCA(Bcfg2.Server.Plugin.GroupSpool): - """ The SSLCA generator handles the creation and management of ssl - certificates and their keys. """ - __author__ = 'g.hagger@gmail.com' - __child__ = Bcfg2.Server.Plugin.FileBacked - key_specs = {} - cert_specs = {} - CAs = {} - def __init__(self, core, datastore): - Bcfg2.Server.Plugin.GroupSpool.__init__(self, core, datastore) - self.infoxml = dict() +class SSLCAXMLSpec(Bcfg2.Server.Plugin.StructFile): + """ Base class to handle key.xml and cert.xml """ + attrs = dict() + tag = None + + def get_spec(self, metadata): + """ Get a specification for the type of object described by + this SSLCA XML file for the given client metadata object """ + entries = [e for e in self.Match(metadata) if e.tag == self.tag] + if len(entries) == 0: + raise PluginExecutionError("No matching %s entry found for %s " + "in %s" % (self.tag, + metadata.hostname, + self.name)) + elif len(entries) > 1: + LOGGER.warning("More than one matching %s entry found for %s in " + "%s; using first match" % (self.tag, + metadata.hostname, + self.name)) + rv = dict() + for attr, default in self.attrs.items(): + val = entries[0].get(attr.lower(), default) + if default in ['true', 'false']: + rv[attr] = val == 'true' + else: + rv[attr] = val + return rv + + +class SSLCAKeySpec(SSLCAXMLSpec): + """ Handle key.xml files """ + attrs = dict(bits='2048', type='rsa') + tag = 'Key' - def HandleEvent(self, event=None): - """ - Updates which files this plugin handles based upon filesystem events. - Allows configuration items to be added/removed without server restarts. - """ - action = event.code2str() - if event.filename[0] == '/': - return - epath = "".join([self.data, self.handles[event.requestID], - event.filename]) - if os.path.isdir(epath): - ident = self.handles[event.requestID] + event.filename - else: - ident = self.handles[event.requestID][:-1] - fname = os.path.join(ident, event.filename) +class SSLCACertSpec(SSLCAXMLSpec): + """ Handle cert.xml files """ + attrs = dict(ca='default', + format='pem', + key=None, + days='365', + C=None, + L=None, + ST=None, + OU=None, + O=None, + emailAddress=None, + append_chain='false') + tag = 'Cert' - if event.filename.endswith('.xml'): + def get_spec(self, metadata): + rv = SSLCAXMLSpec.get_spec(self, metadata) + rv['subjectaltname'] = [e.text for e in self.Match(metadata) + if e.tag == "SubjectAltName"] + return rv + + +class SSLCADataFile(Bcfg2.Server.Plugin.SpecificData): + """ Handle key and cert files """ + def bind_entry(self, entry, _): + """ Bind the data in the file to the given abstract entry """ + entry.text = self.data + return entry + + +class SSLCAEntrySet(Bcfg2.Server.Plugin.EntrySet): + """ Entry set to handle SSLCA entries and XML files """ + def __init__(self, _, path, entry_type, encoding, parent=None): + Bcfg2.Server.Plugin.EntrySet.__init__(self, os.path.basename(path), + path, entry_type, encoding) + self.parent = parent + self.key = None + self.cert = None + + def handle_event(self, event): + action = event.code2str() + fpath = os.path.join(self.path, event.filename) + + if event.filename == 'key.xml': + if action in ['exists', 'created', 'changed']: + self.key = SSLCAKeySpec(fpath) + self.key.HandleEvent(event) + elif event.filename == 'cert.xml': if action in ['exists', 'created', 'changed']: - if event.filename.endswith('key.xml'): - key_spec = lxml.etree.parse(epath, - parser=Bcfg2.Server.XMLParser - ).find('Key') - self.key_specs[ident] = { - 'bits': key_spec.get('bits', '2048'), - 'type': key_spec.get('type', 'rsa') - } - self.Entries['Path'][ident] = self.get_key - elif event.filename.endswith('cert.xml'): - cert_spec = lxml.etree.parse(epath, - parser=Bcfg2.Server.XMLParser - ).find('Cert') - ca = cert_spec.get('ca', 'default') - self.cert_specs[ident] = { - 'ca': ca, - 'format': cert_spec.get('format', 'pem'), - 'key': cert_spec.get('key'), - 'days': cert_spec.get('days', '365'), - 'C': cert_spec.get('c'), - 'L': cert_spec.get('l'), - 'ST': cert_spec.get('st'), - 'OU': cert_spec.get('ou'), - 'O': cert_spec.get('o'), - 'emailAddress': cert_spec.get('emailaddress'), - 'append_chain': - cert_spec.get('append_chain', - 'false').lower() == 'true', - } - self.CAs[ca] = dict(self.core.setup.cfp.items('sslca_%s' % - ca)) - self.Entries['Path'][ident] = self.get_cert - elif event.filename.endswith("info.xml"): - self.infoxml[ident] = Bcfg2.Server.Plugin.InfoXML(epath) - self.infoxml[ident].HandleEvent(event) - if action == 'deleted': - if ident in self.Entries['Path']: - del self.Entries['Path'][ident] + self.cert = SSLCACertSpec(fpath) + self.cert.HandleEvent(event) else: - if action in ['exists', 'created']: - if os.path.isdir(epath): - self.AddDirectoryMonitor(epath[len(self.data):]) - if ident not in self.entries and os.path.isfile(epath): - self.entries[fname] = self.__child__(epath) - self.entries[fname].HandleEvent(event) - if action == 'changed': - self.entries[fname].HandleEvent(event) - elif action == 'deleted': - if fname in self.entries: - del self.entries[fname] - else: - self.entries[fname].HandleEvent(event) - - def get_key(self, entry, metadata): + Bcfg2.Server.Plugin.EntrySet.handle_event(self, event) + + def build_key(self, entry, metadata): """ either grabs a prexisting key hostfile, or triggers the generation of a new key if one doesn't exist. """ - # check if we already have a hostfile, or need to generate a new key # TODO: verify key fits the specs - path = entry.get('name') - filename = os.path.join(path, "%s.H_%s" % (os.path.basename(path), - metadata.hostname)) - if filename not in list(self.entries.keys()): - self.logger.info("SSLCA: Generating new key %s" % filename) - key = self.build_key(entry) - open(self.data + filename, 'w').write(key) - entry.text = key - self.entries[filename] = self.__child__(self.data + filename) - self.entries[filename].HandleEvent() - else: - entry.text = self.entries[filename].data - - entry.set("type", "file") - if path in self.infoxml: - Bcfg2.Server.Plugin.bind_info(entry, metadata, - infoxml=self.infoxml[path]) - else: - Bcfg2.Server.Plugin.bind_info(entry, metadata) - - def build_key(self, entry): - """ generates a new key according the the specification """ - ktype = self.key_specs[entry.get('name')]['type'] - bits = self.key_specs[entry.get('name')]['bits'] + filename = "%s.H_%s" % (os.path.basename(entry.get('name')), + metadata.hostname) + self.logger.info("SSLCA: Generating new key %s" % filename) + key_spec = self.key.get_spec(metadata) + ktype = key_spec['type'] + bits = key_spec['bits'] if ktype == 'rsa': cmd = ["openssl", "genrsa", bits] elif ktype == 'dsa': cmd = ["openssl", "dsaparam", "-noout", "-genkey", bits] self.debug_log("SSLCA: Generating new key: %s" % " ".join(cmd)) - return Popen(cmd, stdout=PIPE).stdout.read() - - def get_cert(self, entry, metadata): - """ - either grabs a prexisting cert hostfile, or triggers the generation - of a new cert if one doesn't exist. - """ - path = entry.get('name') - filename = os.path.join(path, "%s.H_%s" % (os.path.basename(path), - metadata.hostname)) - - # first - ensure we have a key to work with - key = self.cert_specs[entry.get('name')].get('key') - key_filename = os.path.join(key, "%s.H_%s" % (os.path.basename(key), - metadata.hostname)) - if key_filename not in self.entries: - el = lxml.etree.Element('Path') - el.set('name', key) - self.core.Bind(el, metadata) - - # check if we have a valid hostfile - if (filename in self.entries.keys() and - self.verify_cert(filename, key_filename, entry)): - entry.text = self.entries[filename].data - else: - self.logger.info("SSLCA: Generating new cert %s" % filename) - cert = self.build_cert(key_filename, entry, metadata) - open(self.data + filename, 'w').write(cert) - self.entries[filename] = self.__child__(self.data + filename) - self.entries[filename].HandleEvent() - entry.text = cert - - entry.set("type", "file") - if path in self.infoxml: - Bcfg2.Server.Plugin.bind_info(entry, metadata, - infoxml=self.infoxml[path]) - else: - Bcfg2.Server.Plugin.bind_info(entry, metadata) - - def verify_cert(self, filename, key_filename, entry): - """ Perform certification verification against the CA and - against the key """ - ca = self.CAs[self.cert_specs[entry.get('name')]['ca']] - do_verify = ca.get('chaincert') - if do_verify: - return (self.verify_cert_against_ca(filename, entry) and - self.verify_cert_against_key(filename, key_filename)) - return True - - def verify_cert_against_ca(self, filename, entry): - """ - check that a certificate validates against the ca cert, - and that it has not expired. - """ - ca = self.CAs[self.cert_specs[entry.get('name')]['ca']] - chaincert = ca.get('chaincert') - cert = self.data + filename - cmd = ["openssl", "verify"] - is_root = ca.get('root_ca', "false").lower() == 'true' - if is_root: - cmd.append("-CAfile") - else: - # verifying based on an intermediate cert - cmd.extend(["-purpose", "sslserver", "-untrusted"]) - cmd.extend([chaincert, cert]) - self.debug_log("SSLCA: Verifying %s against CA: %s" % - (entry.get("name"), " ".join(cmd))) - res = Popen(cmd, stdout=PIPE, stderr=STDOUT).stdout.read() - if res == cert + ": OK\n": - self.debug_log("SSLCA: %s verified successfully against CA" % - entry.get("name")) - return True - self.logger.warning("SSLCA: %s failed verification against CA: %s" % - (entry.get("name"), res)) - return False - - def verify_cert_against_key(self, filename, key_filename): - """ - check that a certificate validates against its private key. - """ - cert = self.data + filename - key = self.data + key_filename - cert_md5 = \ - md5(Popen(["openssl", "x509", "-noout", "-modulus", "-in", cert], - stdout=PIPE, - stderr=STDOUT).stdout.read().strip()).hexdigest() - key_md5 = \ - md5(Popen(["openssl", "rsa", "-noout", "-modulus", "-in", key], - stdout=PIPE, - stderr=STDOUT).stdout.read().strip()).hexdigest() - if cert_md5 == key_md5: - self.debug_log("SSLCA: %s verified successfully against key %s" % - (filename, key_filename)) - return True - self.logger.warning("SSLCA: %s failed verification against key %s" % - (filename, key_filename)) - return False + proc = Popen(cmd, stdout=PIPE, stderr=PIPE) + key, err = proc.communicate() + if proc.wait(): + raise PluginExecutionError("SSLCA: Failed to generate key %s for " + "%s: %s" % (entry.get("name"), + metadata.hostname, err)) + open(os.path.join(self.path, filename), 'w').write(key) + return key - def build_cert(self, key_filename, entry, metadata): - """ - creates a new certificate according to the specification - """ + def build_cert(self, entry, metadata, keyfile): + """ generate a new cert """ + filename = "%s.H_%s" % (os.path.basename(entry.get('name')), + metadata.hostname) + self.logger.info("SSLCA: Generating new cert %s" % filename) + cert_spec = self.cert.get_spec(metadata) + ca = self.parent.get_ca(cert_spec['ca']) req_config = None req = None try: - req_config = self.build_req_config(entry, metadata) - req = self.build_request(key_filename, req_config, entry) - ca = self.cert_specs[entry.get('name')]['ca'] - ca_config = self.CAs[ca]['config'] - days = self.cert_specs[entry.get('name')]['days'] - passphrase = self.CAs[ca].get('passphrase') - cmd = ["openssl", "ca", "-config", ca_config, "-in", req, + req_config = self.build_req_config(metadata) + req = self.build_request(keyfile, req_config, metadata) + days = cert_spec['days'] + cmd = ["openssl", "ca", "-config", ca['config'], "-in", req, "-days", days, "-batch"] + passphrase = ca.get('passphrase') if passphrase: cmd.extend(["-passin", "pass:%s" % passphrase]) + + def _scrub_pass(arg): + """ helper to scrub the passphrase from the + argument list """ + if arg.startswith("pass:"): + return "pass:******" + else: + return arg + else: + _scrub_pass = lambda a: a + self.debug_log("SSLCA: Generating new certificate: %s" % - " ".join(cmd)) + " ".join(_scrub_pass(a) for a in cmd)) proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) (cert, err) = proc.communicate() if proc.wait(): @@ -266,12 +177,13 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): except OSError: self.logger.error("SSLCA: Failed to unlink temporary files: %s" % sys.exc_info()[1]) - if (self.cert_specs[entry.get('name')]['append_chain'] and - self.CAs[ca]['chaincert']): - cert += open(self.CAs[ca]['chaincert']).read() + if cert_spec['append_chain'] and 'chaincert' in ca: + cert += open(self.parent.get_ca(ca)['chaincert']).read() + + open(os.path.join(self.path, filename), 'w').write(cert) return cert - def build_req_config(self, entry, metadata): + def build_req_config(self, metadata): """ generates a temporary openssl configuration file that is used to generate the required certificate request @@ -298,16 +210,17 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): cfp.add_section(section) for key in defaults[section]: cfp.set(section, key, defaults[section][key]) + cert_spec = self.cert.get_spec(metadata) altnamenum = 1 - altnames = list(metadata.aliases) + altnames = cert_spec['subjectaltname'] + altnames.extend(list(metadata.aliases)) altnames.append(metadata.hostname) for altname in altnames: cfp.set('alt_names', 'DNS.' + str(altnamenum), altname) altnamenum += 1 for item in ['C', 'L', 'ST', 'O', 'OU', 'emailAddress']: - if self.cert_specs[entry.get('name')][item]: - cfp.set('req_distinguished_name', item, - self.cert_specs[entry.get('name')][item]) + if cert_spec[item]: + cfp.set('req_distinguished_name', item, cert_spec[item]) cfp.set('req_distinguished_name', 'CN', metadata.hostname) self.debug_log("SSLCA: Writing temporary request config to %s" % fname) try: @@ -317,16 +230,15 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): "config file: %s" % sys.exc_info()[1]) return fname - def build_request(self, key_filename, req_config, entry): + def build_request(self, keyfile, req_config, metadata): """ creates the certificate request """ fd, req = tempfile.mkstemp() os.close(fd) - days = self.cert_specs[entry.get('name')]['days'] - key = self.data + key_filename + days = self.cert.get_spec(metadata)['days'] cmd = ["openssl", "req", "-new", "-config", req_config, - "-days", days, "-key", key, "-text", "-out", req] + "-days", days, "-key", keyfile, "-text", "-out", req] self.debug_log("SSLCA: Generating new CSR: %s" % " ".join(cmd)) proc = Popen(cmd, stdout=PIPE, stderr=PIPE) err = proc.communicate()[1] @@ -334,3 +246,122 @@ class SSLCA(Bcfg2.Server.Plugin.GroupSpool): raise PluginExecutionError("SSLCA: Failed to generate CSR: %s" % err) return req + + def verify_cert(self, filename, keyfile, entry, metadata): + """ Perform certification verification against the CA and + against the key """ + ca = self.parent.get_ca(self.cert.get_spec(metadata)['ca']) + do_verify = ca.get('chaincert') + if do_verify: + return (self.verify_cert_against_ca(filename, entry, metadata) and + self.verify_cert_against_key(filename, keyfile)) + return True + + def verify_cert_against_ca(self, filename, entry, metadata): + """ + check that a certificate validates against the ca cert, + and that it has not expired. + """ + ca = self.parent.get_ca(self.cert.get_spec(metadata)['ca']) + chaincert = ca.get('chaincert') + cert = os.path.join(self.path, filename) + cmd = ["openssl", "verify"] + is_root = ca.get('root_ca', "false").lower() == 'true' + if is_root: + cmd.append("-CAfile") + else: + # verifying based on an intermediate cert + cmd.extend(["-purpose", "sslserver", "-untrusted"]) + cmd.extend([chaincert, cert]) + self.debug_log("SSLCA: Verifying %s against CA: %s" % + (entry.get("name"), " ".join(cmd))) + res = Popen(cmd, stdout=PIPE, stderr=STDOUT).stdout.read() + if res == cert + ": OK\n": + self.debug_log("SSLCA: %s verified successfully against CA" % + entry.get("name")) + return True + self.logger.warning("SSLCA: %s failed verification against CA: %s" % + (entry.get("name"), res)) + return False + + def verify_cert_against_key(self, filename, keyfile): + """ + check that a certificate validates against its private key. + """ + def _modulus(fname, ftype="x509"): + """ get the modulus from the given file """ + cmd = ["openssl", ftype, "-noout", "-modulus", "-in", fname] + self.debug_log("SSLCA: Getting modulus of %s for verification: %s" + % (fname, " ".join(cmd))) + proc = Popen(cmd, stdout=PIPE, stderr=PIPE) + rv, err = proc.communicate() + if proc.wait(): + self.logger.warning("SSLCA: Failed to get modulus of %s: %s" % + (fname, err)) + return rv.strip() # pylint: disable=E1103 + + certfile = os.path.join(self.path, filename) + cert = _modulus(certfile) + key = _modulus(keyfile, ftype="rsa") + if cert == key: + self.debug_log("SSLCA: %s verified successfully against key %s" % + (filename, keyfile)) + return True + self.logger.warning("SSLCA: %s failed verification against key %s" % + (filename, keyfile)) + return False + + def bind_entry(self, entry, metadata): + if self.key: + self.bind_info_to_entry(entry, metadata) + try: + return self.best_matching(metadata).bind_entry(entry, metadata) + except PluginExecutionError: + entry.text = self.build_key(entry, metadata) + return entry + elif self.cert: + key = self.cert.get_spec(metadata)['key'] + cleanup_keyfile = False + try: + keyfile = self.parent.entries[key].best_matching(metadata).name + except PluginExecutionError: + cleanup_keyfile = True + # create a temp file with the key in it + fd, keyfile = tempfile.mkstemp() + os.chmod(keyfile, 384) # 0600 + el = lxml.etree.Element('Path', name=key) + self.parent.core.Bind(el, metadata) + os.fdopen(fd, 'w').write(el.text) + + try: + self.bind_info_to_entry(entry, metadata) + try: + best = self.best_matching(metadata) + if self.verify_cert(best.name, keyfile, entry, metadata): + return best.bind_entry(entry, metadata) + except PluginExecutionError: + pass + # if we get here, it's because either a) there was no best + # matching entry; or b) the existing cert did not verify + entry.text = self.build_cert(entry, metadata, keyfile) + return entry + finally: + if cleanup_keyfile: + try: + os.unlink(keyfile) + except OSError: + err = sys.exc_info()[1] + self.logger.error("SSLCA: Failed to unlink temporary " + "key %s: %s" % (keyfile, err)) + + +class SSLCA(Bcfg2.Server.Plugin.GroupSpool): + """ The SSLCA generator handles the creation and management of ssl + certificates and their keys. """ + __author__ = 'g.hagger@gmail.com' + es_cls = lambda self, *args: SSLCAEntrySet(*args, parent=self) + es_child_cls = SSLCADataFile + + def get_ca(self, name): + """ get a dict describing a CA from the config file """ + return dict(self.core.setup.cfp.items("sslca_%s" % name)) diff --git a/src/lib/Bcfg2/version.py b/src/lib/Bcfg2/version.py index b4ac47769..f83863cce 100644 --- a/src/lib/Bcfg2/version.py +++ b/src/lib/Bcfg2/version.py @@ -29,8 +29,8 @@ class Bcfg2VersionInfo(tuple): tuple(self) def __repr__(self): - return "(major=%s, minor=%s, micro=%s, releaselevel=%s, serial=%s)" % \ - tuple(self) + return "%s(major=%s, minor=%s, micro=%s, releaselevel=%s, serial=%s)" \ + % ((self.__class__.__name__,) + tuple(self)) def _release_cmp(self, rel1, rel2): # pylint: disable=R0911 """ compare two release numbers """ |