diff --git a/TODO b/TODO new file mode 100644 index 0000000..3f3d94d --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +- I duplicate a lot of code (logging, conf parsing, etc.) from AIF-NG. I should probably just create a common library. diff --git a/example.vaultpass.xml b/example.vaultpass.xml new file mode 100644 index 0000000..cd7c524 --- /dev/null +++ b/example.vaultpass.xml @@ -0,0 +1,11 @@ + + + https://localhost:8000/ + + + + diff --git a/vaultpass/__init__.py b/vaultpass/__init__.py new file mode 100644 index 0000000..e0ab1a9 --- /dev/null +++ b/vaultpass/__init__.py @@ -0,0 +1,11 @@ +import warnings +## +import hvac +## +from . import clipboard +from . import config + + +class PassMan(object): + def __init__(self): + pass diff --git a/vaultpass/clipboard.py b/vaultpass/clipboard.py new file mode 100644 index 0000000..3771612 --- /dev/null +++ b/vaultpass/clipboard.py @@ -0,0 +1,2 @@ +import os +import subprocess diff --git a/vaultpass/config.py b/vaultpass/config.py new file mode 100644 index 0000000..7ad398b --- /dev/null +++ b/vaultpass/config.py @@ -0,0 +1,267 @@ +import copy +import os +import logging +import re +## +import requests +from lxml import etree + + +# TODO: change filehandler of logger? https://stackoverflow.com/a/47447444 +_logger = logging.getLogger('config:{0}'.format(__name__)) + + +class Config(object): + xsd_path = None + tree = None + namespaced_tree = None + xml = None + namespaced_xml = None + raw = None + xsd = None + defaultsParser = None + + def __init__(self, xsd_path = None, *args, **kwargs): + _logger.info('Instantiated {0}.'.format(type(self).__name__)) + + def main(self, validate = True, populate_defaults = True): + self.fetch() + self.parseRaw() + if populate_defaults: + self.populateDefaults() + if validate: + self.validate() + return(None) + + def fetch(self): # Just a fail-safe; this is overridden by specific subclasses. + pass + return(None) + + def getXSD(self, xsdpath = None): + if not xsdpath: + xsdpath = self.xsd_path + raw_xsd = None + base_url = None + if xsdpath: + _logger.debug('XSD path specified.') + orig_xsdpath = xsdpath + xsdpath = os.path.abspath(os.path.expanduser(xsdpath)) + _logger.debug('Transformed XSD path: {0} => {1}'.format(orig_xsdpath, xsdpath)) + if not os.path.isfile(xsdpath): + _logger.error('The specified XSD path {0} does not exist on the local filesystem.'.format(xsdpath)) + raise ValueError('Specified XSD path does not exist') + with open(xsdpath, 'rb') as fh: + raw_xsd = fh.read() + base_url = os.path.split(xsdpath)[0] + else: + _logger.debug('No XSD path specified; getting it from the configuration file.') + xsi = self.xml.nsmap.get('xsi', 'http://www.w3.org/2001/XMLSchema-instance') + _logger.debug('xsi: {0}'.format(xsi)) + schemaLocation = '{{{0}}}schemaLocation'.format(xsi) + schemaURL = self.xml.attrib.get(schemaLocation, + 'https://schema.xml.r00t2.io/projects/vaultpass.xsd') + _logger.debug('Detected schema map: {0}'.format(schemaURL)) + split_url = schemaURL.split() + if len(split_url) == 2: # a properly defined schemaLocation + schemaURL = split_url[1] + else: + schemaURL = split_url[0] # a LAZY schemaLocation + _logger.info('Detected schema location: {0}'.format(schemaURL)) + if schemaURL.startswith('file://'): + schemaURL = re.sub(r'^file://', r'', schemaURL) + _logger.debug('Fetching local file {0}'.format(schemaURL)) + with open(schemaURL, 'rb') as fh: + raw_xsd = fh.read() + base_url = os.path.dirname(schemaURL) + else: + _logger.debug('Fetching remote file: {0}'.format(schemaURL)) + req = requests.get(schemaURL) + if not req.ok: + _logger.error('Unable to fetch XSD.') + raise RuntimeError('Could not download XSD') + raw_xsd = req.content + base_url = os.path.split(req.url)[0] # This makes me feel dirty. + _logger.debug('Loaded XSD at {0} ({1} bytes).'.format(xsdpath, len(raw_xsd))) + _logger.debug('Parsed XML base URL: {0}'.format(base_url)) + self.xsd = etree.XMLSchema(etree.XML(raw_xsd, base_url = base_url)) + _logger.info('Rendered XSD.') + return(None) + + def parseRaw(self, parser = None): + self.xml = etree.fromstring(self.raw, parser = parser) + _logger.debug('Generated xml.') + self.namespaced_xml = etree.fromstring(self.raw, parser = parser) + _logger.debug('Generated namespaced xml.') + self.tree = self.xml.getroottree() + _logger.debug('Generated tree.') + self.namespaced_tree = self.namespaced_xml.getroottree() + _logger.debug('Generated namespaced tree.') + self.tree.xinclude() + _logger.debug('Parsed XInclude for tree.') + self.namespaced_tree.xinclude() + _logger.debug('Parsed XInclude for namespaced tree.') + self.stripNS() + return(None) + + def populateDefaults(self): + _logger.info('Populating missing values with defaults from XSD.') + if not self.xsd: + self.getXSD() + if not self.defaultsParser: + self.defaultsParser = etree.XMLParser(schema = self.xsd, attribute_defaults = True) + self.parseRaw(parser = self.defaultsParser) + return(None) + + def removeDefaults(self): + _logger.info('Removing default values from missing values.') + self.parseRaw() + return(None) + + def stripNS(self, obj = None): + _logger.debug('Stripping namespace.') + # https://stackoverflow.com/questions/30232031/how-can-i-strip-namespaces-out-of-an-lxml-tree/30233635#30233635 + xpathq = "descendant-or-self::*[namespace-uri()!='']" + if not obj: + _logger.debug('No XML object selected; using instance\'s xml and tree.') + for x in (self.tree, self.xml): + for e in x.xpath(xpathq): + e.tag = etree.QName(e).localname + elif isinstance(obj, (etree._Element, etree._ElementTree)): + _logger.debug('XML object provided: {0}'.format(etree.tostring(obj, with_tail = False).decode('utf-8'))) + obj = copy.deepcopy(obj) + for e in obj.xpath(xpathq): + e.tag = etree.QName(e).localname + return(obj) + else: + _logger.error('A non-XML object was provided.') + raise ValueError('Did not know how to parse obj parameter') + return(None) + + def toString(self, stripped = False, obj = None): + if isinstance(obj, (etree._Element, etree._ElementTree)): + _logger.debug('Converting an XML object to a string') + if stripped: + _logger.debug('Stripping before stringifying.') + obj = self.stripNS(obj) + elif obj in ('tree', None): + if not stripped: + _logger.debug('Converting the instance\'s namespaced tree to a string.') + obj = self.namespaced_tree + else: + _logger.debug('Converting the instance\'s stripped tree to a string.') + obj = self.tree + elif obj == 'xml': + if not stripped: + _logger.debug('Converting instance\'s namespaced XML to a string') + obj = self.namespaced_xml + else: + _logger.debug('Converting instance\'s stripped XML to a string') + obj = self.xml + else: + _logger.error(('obj parameter must be "tree", "xml", or of type ' + 'lxml.etree._Element or lxml.etree._ElementTree')) + raise TypeError('Invalid obj type') + obj = copy.deepcopy(obj) + strxml = etree.tostring(obj, + encoding = 'utf-8', + xml_declaration = True, + pretty_print = True, + with_tail = True, + inclusive_ns_prefixes = True) + _logger.debug('Rendered string output successfully.') + return(strxml) + + def validate(self): + if not self.xsd: + self.getXSD() + _logger.debug('Checking validation against namespaced tree.') + self.xsd.assertValid(self.namespaced_tree) + return(None) + + +class LocalFile(Config): + def __init__(self, path, xsd_path = None, *args, **kwargs): + super().__init__(xsd_path = xsd_path, *args, **kwargs) + self.type = 'local' + self.source = path + + def fetch(self): + orig_src = self.source + self.source = os.path.abspath(os.path.expanduser(self.source)) + _logger.debug('Canonized path: {0} => {1}'.format(orig_src, self.source)) + if not os.path.isfile(self.source): + _logger.error('Config at {0} not found.'.format(self.source)) + raise ValueError('Config file does not exist'.format(self.source)) + with open(self.source, 'rb') as fh: + self.raw = fh.read() + _logger.debug('Fetched configuration ({0} bytes).'.format(len(self.raw))) + return(None) + + +class RemoteFile(Config): + def __init__(self, uri, xsd_path = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.type = 'remote' + self.source = uri + + def fetch(self): + r = requests.get(self.source) + if not r.ok(): + _logger.error('Could not fetch {0}'.format(self.source)) + raise RuntimeError('Could not download XML') + self.raw = r.content + _logger.debug('Fetched configuration ({0} bytes).'.format(len(self.raw))) + return(None) + + +class ConfigStr(Config): + def __init__(self, rawxml, xsd_path = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.type = 'raw_str' + self.source = rawxml + + def fetch(self): + self.raw = self.source.encode('utf-8') + _logger.debug('Raw configuration (str) passed in ({0} bytes); converted to bytes.'.format(len(self.raw))) + return(None) + + +class ConfigBin(Config): + def __init__(self, rawbinaryxml, xsd_path = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.type = 'raw_bin' + self.source = rawbinaryxml + + def fetch(self): + self.raw = self.source + _logger.debug('Raw configuration (binary) passed in ({0} bytes); converted to bytes.'.format(len(self.raw))) + return(None) + + +detector = {'raw': (re.compile(r'^\s*(?P<(\?xml|vaultpass)\s+.*)\s*$', re.DOTALL | re.MULTILINE), ConfigStr), + 'remote': (re.compile(r'^(?P(?P(https?|ftps?)://)(?P.*))\s*$'), RemoteFile), + 'local': (re.compile(r'^(file://)?(?P(/?[^/]+)+/?)$'), LocalFile)} + + +def getConfig(cfg_ref, validate = True, populate_defaults = True, xsd_path = None): + cfgobj = None + # This is kind of gross. + for configtype, (pattern, configClass) in detector.items(): + try: + if pattern.search(cfg_ref): + cfgobj = configClass(cfg_ref, xsd_path = xsd_path) + _logger.info('Config detected as {0}.'.format(configtype)) + break + except TypeError: + ptrn = re.compile(detector['raw'][0].pattern.encode('utf-8')) + if not ptrn.search(cfg_ref): + _logger.error('Could not detect which configuration type was passed.') + raise ValueError('Unexpected/unparseable cfg_ref.') + else: + _logger.info('Config detected as ConfigBin.') + cfgobj = ConfigBin(cfg_ref, xsd_path = xsd_path) + break + if cfgobj: + _logger.info('Parsing configuration.') + cfgobj.main(validate = validate, populate_defaults = populate_defaults) + return(cfgobj) diff --git a/vaultpass/logger.py b/vaultpass/logger.py new file mode 100644 index 0000000..580864b --- /dev/null +++ b/vaultpass/logger.py @@ -0,0 +1,54 @@ +import logging +import logging.handlers +import os +## +try: + # https://www.freedesktop.org/software/systemd/python-systemd/journal.html#journalhandler-class + from systemd import journal + _has_journald = True +except ImportError: + _has_journald = False + + +logfile = os.path.abspath(os.path.expanduser('~/.cache/vaultpass/vaultpass.log')) + + +def prepLogfile(path = logfile): + path = os.path.abspath(os.path.expanduser(path)) + # Set up the permissions beforehand. + os.makedirs(os.path.dirname(logfile), exist_ok = True, mode = 0o0700) + os.chmod(logfile, 0o0600) + return(path) + + +_cfg_args = {'handlers': [], + 'level': logging.DEBUG} # TEMPORARY FOR TESTING +if _has_journald: + # There were some weird changes somewhere along the line. + try: + # But it's *probably* this one. + h = journal.JournalHandler() + except AttributeError: + h = journal.JournaldLogHandler() + # Systemd includes times, so we don't need to. + h.setFormatter(logging.Formatter(style = '{', + fmt = ('{name}:{levelname}:{name}:{filename}:' + '{funcName}:{lineno}: {message}'))) + _cfg_args['handlers'].append(h) +# Logfile +h = logging.handlers.RotatingFileHandler(prepLogfile(), + encoding = 'utf8', + # Disable rotating for now. + # maxBytes = 50000000000, + # backupCount = 30 + ) +h.setFormatter(logging.Formatter(style = '{', + fmt = ('{asctime}:' + '{levelname}:{name}:{filename}:' + '{funcName}:{lineno}: {message}'))) +_cfg_args['handlers'].append(h) + +logging.basicConfig(**_cfg_args) +logger = logging.getLogger() + +logger.info('Logging initialized.')