diff --git a/TODO b/TODO index a02dcff..946c8a6 100644 --- a/TODO +++ b/TODO @@ -3,6 +3,16 @@ - ensure we use docstrings in a Sphinx-compatible manner? https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html at the very least document all the functions and such so pydoc's happy. +- better prompt display. i might include them as constants in a separate file + and then import it for e.g. confgen. or maybe a Flask website/app? +- locking +- for docs, 3.x (as of 3.10) was 2.4M. +- GUI? at least for generating config... + +- need to package: + python-hashid (https://psypanda.github.io/hashID/, + https://github.com/psypanda/hashID, + https://pypi.org/project/hashID/) - package for PyPI: # https://packaging.python.org/tutorials/distributing-packages/ @@ -14,6 +24,7 @@ BUGS.SQUARE-R00T.NET bugs/tasks: #7: Ensure conditional deps/imports for features only if used. Is this setup.py-compatible? + nooope. just make everything a dep. #14: Use os.path.join() for more consistency/pythonicness #24: Run as regular user? (pychroot? fakeroot?) #34: Build-time support for only building single phase of build diff --git a/bdisk/GPG.py b/bdisk/GPG.py index 4c3cbfc..6f22c16 100644 --- a/bdisk/GPG.py +++ b/bdisk/GPG.py @@ -1 +1,85 @@ import gpg +import os +import psutil +import gpg.errors + +class GPGHandler(object): + def __init__(self, gnupg_homedir = None, key_id = None, keyservers = None): + self.home = gnupg_homedir + self.key_id = key_id + self.keyservers = keyservers + if self.home: + self._prep_home() + else: + self._check_home() + self.ctx = self.get_context(home_dir = self.home) + + def _check_home(self, home = None): + if not home: + home = self.home + if not home: + self.home = os.environ.get('GNUPGHOME', '~/.gnupg') + home = self.home + self._prep_home(home) + return() + + def _prep_home(self, home = None): + if not home: + home = self.home + if not home: + self.home = os.environ.get('GNUPGHOME', '~/.gnupg') + self.home = os.path.abspath(os.path.expanduser(self.home)) + if os.path.isdir(self.home): + _exists = True + else: + _exists = False + _uid = os.getuid() + _gid = os.getgid() + try: + os.makedirs(self.home, exist_ok = True) + os.chown(self.home, _uid, _gid) + os.chmod(self.home, 0o700) + except PermissionError: + # It's alright; it's HOPEFULLY already created. + if not _exists: + raise PermissionError('We need a GnuPG home directory we can ' + 'write to') + return() + + def get_context(self, **kwargs): + ctx = gpg.Context(**kwargs) + return(ctx) + + def kill_stale_agent(self): + _process_list = [] + # TODO: optimize; can I search by proc name? + for p in psutil.process_iter(): + if (p.name() in ('gpg-agent', 'dirmngr') and \ + p.uids()[0] == os.getuid()): + pd = psutil.Process(p.pid).as_dict() + # TODO: convert these over +# for d in (chrootdir, dlpath): +# if pd['cwd'].startswith('{0}'.format(d)): +# plst.append(p.pid) +# if len(plst) >= 1: +# for p in plst: +# psutil.Process(p).terminate() + + def get_sigs(self, data_in): + key_ids = [] + # Currently as of May 13, 2018 there's no way using the GPGME API to do + # the equivalent of the CLI's --list-packets. + # https://lists.gnupg.org/pipermail/gnupg-users/2018-January/ + # 059708.html + # https://lists.gnupg.org/pipermail/gnupg-users/2018-January/ + # 059715.html + # We use the "workaround in: + # https://lists.gnupg.org/pipermail/gnupg-users/2018-January/ + # 059711.html + try: + self.ctx.verify(data_in) + except gpg.errors.BadSignatures as sig_except: + for line in [i.strip() for i in str(sig_except).splitlines()]: + l = [i.strip() for i in line.split(':')] + key_ids.append(l[0]) + return(key_ids) diff --git a/bdisk/bdisk.xsd b/bdisk/bdisk.xsd index e69de29..2e4f92c 100644 --- a/bdisk/bdisk.xsd +++ b/bdisk/bdisk.xsd @@ -0,0 +1,6 @@ + + + diff --git a/bdisk/confgen.py b/bdisk/confgen.py new file mode 100755 index 0000000..0b8ccea --- /dev/null +++ b/bdisk/confgen.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3.6 + +import confparse +import crypt +import getpass +import os +import utils +import uuid +import lxml.etree + +detect = utils.detect() +generate = utils.generate() +prompt = utils.prompts() +transform = utils.transform() +valid = utils.valid() + +# TODO: convert the restarts for prompts to continue's instead of letting them +# continue on to the next prompt. + +def pass_prompt(user): + # This isn't in utils.prompts() because we need to use an instance of + # utils.valid() and it feels like it belongs here, since it's only usable + # for configuration generation. + passwd = {'hashed': None, + 'password': None, + 'hash_algo': None, + 'salt': None} + _special_password_values = ('BLANK', '') + _passwd_is_special = False + _need_input_type = True + while _need_input_type: + _input_type = input('\nWill you be entering a password or a salted ' + 'hash? (If using a "special" value per the manual, ' + 'use 1 (password)):\n\n' + '\t\t1: password\n' + '\t\t2: salted hash\n\n' + 'Choice: ').strip() + if not valid.integer(_input_type): + print('You must enter 1 or 2.') + else: + if int(_input_type) == 1: + _input_type = 'password' + _need_input_type = False + passwd['hashed'] = False + elif int(input_type) == 2: + _input_type = 'salted hash' + _need_input_type = False + passwd['hashed'] = True + else: + print('You must enter 1 or 2.') + _prompt = ('\nWhat do you want {0}\'s {1} to be?\n').format(user, + _input_type) + if passwd['hashed']: + passwd['password'] = input('{0}\n{1}: '.format(_prompt, + _input_type.title())) + if not valid.password_hash: + print('This is not a valid password hash. Re-running.') + pass_prompt(user) + else: + passwd['password'] = getpass.getpass(_prompt + ('See the manual for ' + 'special values.\nYour input will NOT ' + 'echo back (unless it\'s a special value).\n' + '{0}: ').format(_input_type.title())) + if passwd['password'] in _special_password_values: + _passwd_is_special = True + # 'BLANK' => '' => <(root)password> + if passwd['password'] == 'BLANK': + passwd['password'] == '' + # '' => None => <(root)password /> + elif passwd['password'] == '': + passwd['password'] == None + if not valid.password(passwd['password']): + print('As a safety precaution, we are refusing to use this ' + 'password. It should entirely consist of the 95 printable ' + 'ASCII characters. Consult the manual\'s section on ' + 'passwords for more information.\nLet\'s try this again, ' + 'shall we?') + pass_prompt(user) + _salt = input('\nEnter the salt to use. If left blank, one will be ' + 'automatically generated. See the manual for special ' + 'values.\nSalt: ').strip() + if _salt == '': + pass + elif _salt == 'auto': + passwd['salt'] = 'auto' + elif not valid.salt_hash(): + print('This is not a valid salt. Let\'s try this again.') + pass_prompt(user) + else: + passwd['salt'] = _salt + _algo = input(('\nWhat algorithm should we use to hash the password? ' + 'The default is sha512. You can choose from the ' + 'following:\n\n' + '\t\t{0}\n\nAlgorithm: ').format( + '\n\t\t'.join(list(utils.crypt_map.keys())) + )).strip().lower() + if _algo == '': + _algo = 'sha512' + if _algo not in utils.crypt_map: + print('Algorithm not found; let\'s try this again.') + pass_prompt(user) + else: + passwd['hash_algo'] = _algo + if _salt == '': + passwd['salt'] = generate.salt(_algo) + if not _passwd_is_special: + _gen_now = prompt.confirm_or_no(prompt = '\nGenerate a password ' + 'hash now? This is HIGHLY recommended; otherwise, ' + 'the plaintext password will be stored in the ' + 'configuration and that is no bueno.\n') + if _gen_now: + passwd['password'] = generate.hash_password( + passwd['password'], + salt = passwd['salt'], + algo = passwd['hash_algo']) + passwd['hashed'] = True + return(passwd) + +class ConfGenerator(object): + def __init__(self, cfgfile = None, append_config = False): + if append_config: + if not cfgfile: + raise RuntimeError('You have specified config appending but ' + 'did not provide a configuration file') + if cfgfile: + self.cfgfile = os.path.abspath(os.path.expanduser(cfgfile)) + else: + # Write to STDOUT + self.cfgfile = None + c = confparse.Conf(cfgfile) + self.cfg = c.xml + self.append = True + else: + self.cfg = lxml.etree.Element('bdisk') + self.append = False + self.profile = lxml.etree.Element('profile') + self.cfg.append(self.profile) # do I need to do this at the end? + + def main(self): + print(('\n\tPlease consult the manual at {manual_site} if you have ' + 'any questions.' + '\n\tYou can hit CTRL-c at any time to quit.\n' + ).format(manual_site = 'https://bdisk.square-r00t.net/')) + try: + self.get_profile_attribs() + self.get_meta() + self.get_accounts() + self.get_sources() + except KeyboardInterrupt: + exit('\n\nCaught KeyboardInterrupt; quitting...') + return() + + def get_profile_attribs(self): + print('++ PROFILE ATTRIBUTES ++') + id_attrs = {'name': None, + 'id': None, + 'uuid': None} + while not any(tuple(id_attrs.values())): + print('\nThese are used to uniquely identify the profile you are ' + 'creating. To ensure compatibility with other processes, ' + 'each profile MUST be unique (even if you\'re only storing ' + 'one profile per file). That means at least ONE of these ' + 'attributes must be populated. You can hit enter to leave ' + 'the attribute blank - you don\'t need to provide ALL ' + 'attributes (though it\'s certainly recommended).') + id_attrs['name'] = transform.sanitize_input( + (input( + '\nWhat name should this profile be? (It will ' + 'be transformed to a safe string if ' + 'necessary.)\nName: ') + )) + id_attrs['id'] = transform.sanitize_input( + (input( + '\nWhat ID number should this profile have? It MUST be a ' + 'positive integer.\nID: ') + ).strip()) + if id_attrs['id']: + if not valid.integer(id_attrs['id']): + print('Invalid; skipping...') + id_attrs['id'] = None + # We don't sanitize this because it'd break. UUID4 requires hyphen + # separators. We still validate, though. + id_attrs['uuid'] = input( + '\nWhat UUID should this profile have? ' + 'It MUST be a UUID4 (RFC4122 § 4.4). e.g.:\n' + '\t333d7287-3caa-45fe-b954-2da15dad1212\n' + 'If you use the special value "auto" (without quotes), then ' + 'one will be automatically generated for you.\nUUID: ').strip() + if id_attrs['uuid'].lower() == 'auto': + id_attrs['uuid'] = str(uuid.uuid4()) + print('\n\tGenerated a UUID: {0}\n'.format(id_attrs['uuid'])) + else: + if not valid.uuid(id_attrs['uuid']): + print('Invalid; skipping...') + id_attrs['uuid'] = None + # This causes a looping if none of the answers are valid. + for i in id_attrs: + if id_attrs[i] == '': + id_attrs[i] = None + for i in id_attrs: + if id_attrs[i]: + self.profile.attrib[i] = id_attrs[i] + print() + return() + + def get_meta(self): + print('\n++ META ITEMS ++') + meta_items = {'names': {'name': None, + 'uxname': None, + 'pname': None}, + 'desc': None, + 'uri': None, + 'ver': None, + 'dev': {'author': None, + 'email': None, + 'website': None}, + 'max_recurse': None} + while (not transform.flatten_recurse(meta_items) or \ + (None in transform.flatten_recurse(meta_items))): + print('\nThese are used primarily for branding (with the ' + 'exception of recursion level, which is used ' + 'operationally).\n*All* items are REQUIRED (and if any are ' + 'blank or invalid, the entire section will restart), but ' + 'you may want to tweak the VERSION_INFO.txt.j2 template if ' + 'you don\'t want this information exposed to your users ' + '(see the manual for more detail).') + print('\n++ META ITEMS || NAMES ++') + # https://en.wikipedia.org/wiki/8.3_filename + meta_items['names']['name'] = transform.sanitize_input( + input( + '\nWhat 8.3 filename should be used as the name of this ' + 'project/live distro? Refer to the manual\'s Configuration ' + 'section for path /bdisk/profile/meta/names/name for ' + 'restrictions (there are quite a few).\n8.3 Name: ').strip(), + no_underscores = True).upper() + if (len(meta_items['names']['name']) > 8) or ( + meta_items['names']['name'] == ''): + print('Invalid; skipping...') + meta_items['names']['name'] = None + # Note: 2009 spec + # http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_282 + meta_items['names']['uxname'] = input( + '\nWhat name should be used as the "human-readable" name of ' + 'this project/live distro? Refer to the manual\'s ' + 'Configuration section for path ' + '/bdisk/profile/meta/names/uxname for restrictions, but in a ' + 'nutshell it must be compatible with the "POSIX Portable ' + 'Filename Character Set" specification (the manual has a ' + 'link).\nName: ').strip() + if not valid.posix_filename(meta_items['names']['uxname']): + print('Invalid; skipping...') + meta_items['names']['uxname'] = None + meta_items['names']['pname'] = input( + '\nWhat name should be used as the "pretty" name of this ' + 'project/live distro? Refer to the manual\'s Configuration ' + 'section for path /bdisk/profile/meta/names/uxname for ' + 'restrictions, but this is by far the most lax naming. It ' + 'should be used for your actual branding.\nName: ').strip() + if meta_items['names']['pname'] == '': + meta_items['names']['pname'] = None + print('\n++ META ITEMS || PROJECT INFORMATION ++') + meta_items['uri'] = input('\nWhat is your project\'s URI/URL?' + '\nURL: ').strip() + if not valid.url(meta_items['uri']): + print('Invalid; skipping...') + meta_items['uri'] = None + meta_items['ver'] = input( + '\nWhat version is this project? It follows the same rules as ' + 'the POSIX filename specification mentioned earlier (as we ' + 'use it to name certain files).\nVersion: ') + while not meta_items['desc']: + print('\nWhat is your project\'s description?' + '\nAccepts multiple lines, etc.' + '\nPress CTRL-d (on *nix/macOS) or CTRL-z (on Windows) ' + 'on an empty line when done.' + '\nIt will be echoed back for confirmation after it is ' + 'entered (with the option to re-enter if ' + 'desired/needed - this will NOT restart the entire Meta ' + 'section).') + meta_items['desc'] = prompt.multiline_input( + prompt = '\nDescription: ') + print('-----\n{0}\n-----'.format(meta_items['desc'])) + _confirm = prompt.confirm_or_no( + prompt = 'Does this look okay?\n') + if not _confirm: + meta_items['desc'] = None + print('\n++ META ITEMS || DEVELOPER INFORMATION ++') + meta_items['dev']['author'] = (input( + '\nWhat is YOUR name?\nName: ')).strip() + meta_items['dev']['email'] = (input('\nWhat is your email address?' + '\nemail: ')).strip() + if not valid.email(meta_items['dev']['email']): + print('Invalid; skipping...') + meta_items['dev']['email'] = None + meta_items['dev']['website'] = (input('\nWhat is your website?\n' + 'Website: ')).strip() + if not valid.url(meta_items['dev']['website']): + print('Invalid; skipping...') + meta_items['dev']['website'] = None + print('\n++ META ITEMS || OPERATIONAL CONFIGURATION ++') + meta_items['max_recurse'] = transform.sanitize_input(input( + '\nAs of the 4.x branch, BDisk configuration files support ' + 'cross-document substitution via XPath references, even ' + 'recursively. How many levels of recursion do you want this ' + 'profile to support? Note that the default limit for Python ' + 'is 1000 (and CAN be changed, but is not recommended) and ' + 'each level of recursion you add can POTENTIALLY add ' + 'additional CPU/RAM strain. HOWEVER, chances are if your ' + 'machine\'s good enough to run BDisk, it\'s good enough for ' + 'whatever you set. I recommend setting it to 5, because any ' + 'more than that and your configuration becomes cumbersome to ' + 'maintain.\nMax recursion: ').strip()) + if not valid.integer(meta_items['max_recurse']): + print('Invalid; skipping...') + meta_items['dev']['website'] = None + meta = lxml.etree.SubElement(self.profile, 'meta') + for e in meta_items: + elem = lxml.etree.SubElement(meta, e) + # These have nested items. + if isinstance(meta_items[e], dict): + for s in meta_items[e]: + subelem = lxml.etree.SubElement(elem, s) + subelem.text = meta_items[e][s] + else: + elem.text = meta_items[e] + print() + return() + + def get_accounts(self): + print('\n++ ACCOUNTS ++') + accounts = lxml.etree.SubElement(self.profile, 'accounts') + pass_attribs = ('hashed', 'hash_algo', 'salt') + rootpass = None + print('\n++ ACCOUNTS || ROOT ++') + if not rootpass: + prompt_attribs = pass_prompt('root') + rootpass = lxml.etree.Element('rootpass') + for i in pass_attribs: + rootpass.attrib[i] = transform.py2xml(prompt_attribs[i]) + rootpass.text = prompt_attribs['password'] + accounts.append(rootpass) + print('\n++ ACCOUNTS || USERS ++') + more_accounts = prompt.confirm_or_no(prompt = ('\nWould you like to ' + 'add a non-root/regular user?\n'), + usage = ('{0} for yes, {1} for no...\n')) + users = lxml.etree.SubElement(accounts, 'users') + while more_accounts: + user = None + _user_invalid = True + _user_text = {'username': None, + 'password': None, + 'comment': None} + while _user_invalid: + _username = (input('\nWhat should the username be?' + '\nUsername: ')).strip() + if not valid.username(_username): + print('\nThat username string is invalid. Consult the ' + 'manual and the man page for useradd(8). Let\'s ' + 'have another go.') + else: + _user_text['username'] = _username + _user_invalid = False + _sudo = prompt.confirm_or_no(prompt = ('\nGive {0} full sudo ' + 'access?\n').format(_username)) + _pass_attr = pass_prompt(_username) + _user_text['password'] = _pass_attr['password'] + _user_text['comment'] = transform.no_newlines( + (input('\nWhat do you want the GECOS comment to be? This is ' + 'USUALLY the full "real" name of the user (or a ' + 'description of the service, etc.). You can leave it ' + 'blank if you want.\nGECOS: ')).strip()) + user = lxml.etree.Element('user') + user.attrib['sudo'] = transform.py2xml(_sudo) + _elems = {} + for elem in _user_text: + _elems[elem] = lxml.etree.SubElement(user, elem) + _elems[elem].text = _user_text[elem] + for i in pass_attribs: + _elems['password'].attrib[i] = transform.py2xml(_pass_attr[i]) + users.append(user) + more_accounts = prompt.confirm_or_no(prompt = ('\nWould you like ' + 'to add another user?\n'), + usage = ('{0} for yes, {1} ' + 'for no...\n')) + return() + + def get_sources(self): + print('\n++ SOURCES ++') + sources = lxml.etree.SubElement(self.profile, 'sources') + more_sources = True + _arches = [] + _supported_arches = {'x86': ('(Also referred to by distros as "i386", ' + '"i486", "i686", and "32-bit")'), + 'x86_64': ('(Also referred to by distros as ' + '"64-bit")')} + while more_sources: + if len(_arches) == len(_supported_arches): + # All supported arches have been added. We currently don't + # support mirror-balancing. TODO? + print('\nCannot add more sources; all supported architectures ' + 'have been used. Moving on.') + more_sources = False + break + if len(_arches) > 0: + print('\n(Currently added arches: {0})'.format( + ', '.join(_arches))) + _print_arches = '\n\t'.join( + ['{0}:\t{1}'.format(*i) for i in _supported_arches.items()]) + source = lxml.etree.Element('source') + arch = (input(( + '\nWhat hardware architecture is this source for?\n(Note: ' + 'BDisk currently only supports the listed architectures).\n' + '\n\t{0}\n\nArch: ').format(_print_arches))).strip().lower() + if arch not in _supported_arches.keys(): + print('That is not a supported architecture. Trying again.') + continue + source.attrib['arch'] = arch + print('\n++ SOURCES || {0} ++'.format(arch.upper())) + print('\n++ SOURCES || {0} || TARBALL ++'.format(arch.upper())) + tarball = (input('\nWhat URL should be used for the tarball? ' + '(Note that this is ONLY tested for syntax, we ' + 'don\'t confirm it\'s downloadable when running ' + 'through the configuration generator wizard - ' + 'so please make sure you enter the correct URL!)' + '\nTarball: ')).strip() + if not valid.url(tarball): + print('That isn\'t a valid URL. Please double-check and try ' + 'again.') + continue + tarball = transform.url_to_dict(tarball, no_None = True) + tarball_elem = lxml.etree.SubElement(source, 'tarball') + tarball_elem.attrib['flags'] = 'latest' + tarball_elem.text = tarball['full_url'] + print('\n++ SOURCES || {0} || CHECKSUM ++'.format(arch.upper())) + chksum = lxml.etree.SubElement(source, 'checksum') + _chksum_chk = prompt.confirm_or_no(prompt = ( + '\nWould you like to add a checksum for the tarball? (BDisk ' + 'can fetch a checksum file from a remote URL at build-time or ' + 'you can hardcode an explicit checksum in.)\n'), + usage = ('{0} for yes, {1} ' + 'for no...\n')) + if not _chksum_chk: + checksum = None + else: + checksum = (input( + '\nPlease enter the URL to the checksum file OR the ' + 'explicit checksum you wish to use.\nChecksum (remote URL ' + 'or checksum hash): ')).strip() + if valid.url(checksum): + checksum = transform.url_to_dict(checksum) + checksum_type = prompt.hash_select(prompt = ( + '\nPlease select the digest type (by number) of the ' + 'checksums contained in this file.\n' + 'Can be one of:\n\n\t{0}' + '\n\nChecksum type: ')) + if checksum_type is False: + print('Select by NUMBER. Starting over.') + continue + elif checksum_type is None: + print('Invalid selection. Starting over.') + continue + chksum = lxml.etree.SubElement(source, 'checksum') + chksum.attrib['hash_algo'] = checksum_type + chksum.attrib['explicit'] = "no" + chksum.text = checksum['full_url'] + else: + # Maybe it's a digest string. + checksum_type = detect.any_hash(checksum) + if not checksum_type: + print('\nCould not detect which hash type this digest ' + 'is.') + checksum_type = prompt.hash_select( + prompt = ('\nPlease select from the following ' + 'list (by numer):\n\n\t{0}' + '\n\nChecksum type: ')) + if checksum_type is False: + print('Select by NUMBER. Starting over.') + continue + elif checksum_type is None: + print('Invalid selection. Starting over.') + continue + elif len(checksum_type) > 1: + checksum_type = prompt.hash_select( + prompt = ( + '\nWe found several algorithms that can match ' + 'your provided digest.\nPlease select the ' + 'appropriate digest method from the list below ' + '(by number):\n\n\t{0}\n\nChecksum type: ')) + if checksum_type is False: + print('Select by NUMBER. Starting over.') + continue + elif checksum_type is None: + print('Invalid selection. Starting over.') + continue + else: + checksum_type == checksum_type[0] + chksum.attrib['explicit'] = "yes" + chksum.text = checksum + chksum.attrib['hash_algo'] = checksum_type + print('\n++ SOURCES || {0} || GPG ++'.format(arch.upper())) + sig = lxml.etree.SubElement(source, 'sig') + _gpg_chk = prompt.confirm_or_no(prompt = ( + '\nWould you like to add a GPG(/GnuPG/PGP) signature for the ' + 'tarball?\n')) + if _gpg_chk: + gpgsig = (input( + '\nPlease enter the remote URL for the GPG signature ' + 'file.\nGPG Signature File URL: ') + ).strip() + if not valid.url(gpgsig): + print('Invalid URL. Starting over.') + continue + else: + gpgsig = transform.url_to_dict(gpgsig) + sig.text = gpgsig['full_url'] + sigkeys = prompt.confirm_or_no(prompt = ( + '\nDo you know the key ID of the authorized/valid ' + 'signer? (If not, we will fetch the GPG signature file ' + 'now and try to parse it for key IDs.)\n'), + usage = ('{0} for yes, {1} ' + 'for no...\n')) + if sigkeys: + sigkeys = (input('\nWhat is the key ID? You can use the ' + 'fingerprint, full 40-character key ID ' + '(preferred), 16-character "long" ID, or ' + 'the 8-character "short" ID ' + '(HIGHLY unrecommended!).\nKey ID: ') + ).strip().upper() + if not valid.gpgkeyID(sigkeys): + print('That is not a valid GPG key ID. Restarting') + continue + sig.attrib['keys'] = sigkeys + else: + sigkeys = detect.gpgkeyID_from_url(gpgsig) + if not isinstance(sigkeys, list): + print('Could not properly parse any keys in the ' + 'signature file. Restarting.') + continue + elif len(sigkeys) == 0: + print('We didn\'t find any key IDs embedded in the ' + 'given signature file. Restarting.') + continue + elif len(sigkeys) == 1: + _s = 'Does this key' + else: + _s = 'Do these keys' + _key_info = [detect.gpgkey_info(k) for k in sigkeys] + print('\nWe found the following key ID information:\n\n') + for _key in _key_info: + print('\t{0}\n'.format(_key['Full key'])) + for _uid in _key['User IDs']: + # COULD flatten this to just one level. + print('\t\t{0}'.format(_uid['Name'])) + for k in _uid: + if k != 'Name': + print('\t\t\t{0}:\t{1}'.format(k, _uid[k])) + _key_chk = prompt.confirm_or_no(prompt = ( + '\n{0} look correct?\n').format(_s)) + if not _key_chk: + print('Something must have gotten futzed, then.' + 'Restarting!') + continue + sig.attrib['keys'] = ','.join(sigkeys) + elems = {} + for s in ('mirror', 'webroot'): + elems[s] = lxml.etree.SubElement(source, s) + elems['mirror'].text = '{scheme}://{host}'.format(**tarball) + if tarball['port'] != '': + elems['mirror'].text += ':{0}'.format(tarball['port']) + elems['webroot'].text = '{path}'.format(**tarball) + _arches.append(arch) + more_sources = prompt.confirm_or_no(prompt = ('\nWould you like ' + 'to add another ' + 'source?\n'), + usage = ('{0} for yes, {1} ' + 'for no...\n')) + return() + +def main(): + cg = ConfGenerator() + cg.main() + print() + print(lxml.etree.tostring(cg.cfg, + pretty_print = True, + encoding = 'UTF-8', + xml_declaration = True + ).decode('utf-8')) + +if __name__ == '__main__': + main() diff --git a/bdisk/confparse.py b/bdisk/confparse.py index 82a7f64..2e74073 100644 --- a/bdisk/confparse.py +++ b/bdisk/confparse.py @@ -1,10 +1,10 @@ import _io import copy +import re import os import validators from urllib.parse import urlparse import lxml.etree -import lxml.objectify as objectify etree = lxml.etree @@ -14,24 +14,25 @@ def _detect_cfg(cfg): if isinstance(cfg, str): # check for path or string try: - etree.fromstring(cfg) + etree.fromstring(cfg.encode('utf-8')) + return(cfg.encode('utf-8')) except lxml.etree.XMLSyntaxError: path = os.path.abspath(os.path.expanduser(cfg)) try: - with open(path, 'r') as f: + with open(path, 'rb') as f: cfg = f.read() except FileNotFoundError: raise ValueError('Could not open {0}'.format(path)) elif isinstance(cfg, _io.TextIOWrapper): - _cfg = cfg.read() + _cfg = cfg.read().encode('utf-8') cfg.close() cfg = _cfg elif isinstance(self.cfg, _io.BufferedReader): - _cfg = cfg.read().decode('utf-8') + _cfg = cfg.read() cfg.close() cfg = _cfg elif isinstance(cfg, bytes): - cfg = cfg.decode('utf-8') + return(cfg) else: raise TypeError('Could not determine the object type.') return(cfg) @@ -76,7 +77,15 @@ class Conf(object): self.profile = profile self.xml = None self.profile = None - self.xml = etree.from_string(self.cfg) + # Mad props to https://stackoverflow.com/a/12728199/733214 + self.xpath_re = re.compile('(?<=(? ', + end_str = '\n(End signal received)'): + _lines = [] + if prompt: + # This grabs the first CR/LF. + _lines.append(input(prompt)) + try: + while True: + if continue_str: + _lines.append(input(continue_str)) + else: + _lines.append(input()) + except EOFError: + if end_str: + print(end_str) + return('\n'.join(_lines)) + +class transform(object): + def __init__(self): + pass + + def flatten_recurse(self, obj, values = []): + _values = values + if isinstance(obj, list): + _values += obj + elif isinstance(obj, str): + _values.append(obj) + elif isinstance(obj, dict): + for k in obj: + self.flatten_recurse(obj[k], values = _values) + return(_values) + + def no_newlines(self, text_in): + text = re.sub('\n+', ' ', text_in) + return(text) + + def py2xml(self, value, attrib = True): + if value in (False, ''): + if attrib: + return("no") + else: + return(None) + elif isinstance(value, bool): + # We handle the False case above. + return("yes") + elif isinstance(value, str): + return(value) + else: + # We can't do it simply. + return(value) + + def sanitize_input(self, text_in, no_underscores = False): + if no_underscores: + _ws_repl = '' + else: + _ws_repl = '_' + # First we convert spaces to underscores (or remove them entirely). + text_out = re.sub('\s+', _ws_repl, text_in.strip()) + # Then just strip out all symbols. + text_out = re.sub('[^\w]', '', text_out) + return(text_out) + + def url_to_dict(self, orig_url, no_None = False): + def _getuserinfo(uinfo_str): + if len(uinfo_str) == 0: + if no_None: + return('') + else: + return(None) + else: + uinfo_str = uinfo_str[0] + _l = [i.strip() for i in uinfo_str.split(':') if i.strip() != ''] + if len(_l) == 1: + _l.append('') + elif len(_l) == 0: + if no_None: + return('') + else: + return(None) + uinfo = {} + if not no_None: + uinfo['user'] = (None if _l[0] == '' else _l[0]) + uinfo['password'] = (None if _l[1] == '' else _l[1]) + else: + uinfo['user'] = _l[0] + uinfo['password'] = _l[1] + return(uinfo) + def _getdfltport(): + with open('/etc/services', 'r') as f: + _svcs = f.read() + _svcs = [i.strip() for i in _svcs.splitlines() if i.strip() != ''] + svcs = {} + for x in _svcs: + if re.search('^\s*#', x): + continue + s = re.sub('^\s*(\w\s+\w)(\s|\s*#)*.*$', '\g<1>', x) + l = [i.strip() for i in s.split()] + p = (int(l[1].split('/')[0]), l[1].split('/')[1]) + if l[0] not in svcs: + svcs[l[0]] = [] + if len(svcs[l[0]]) > 0: + # If it has a TCP port, put that first. + for idx, val in enumerate(svcs[l[0]]): + if val['proto'].lower() == 'tcp': + svcs[l[0]].insert(0, svcs[l[0]].pop(idx)) + svcs[l[0]].append({'port': p[0], + 'proto': p[1]}) + return(svcs) + def _subsplitter(in_str, split_char): + if in_str == '': + if not no_None: + return(None) + else: + return('') + params = {} + for i in in_str.split(split_char): + p = [x.strip() for x in i.split('=')] + params[p[0]] = p[1] + if not params: + if not no_None: + return(None) + else: + return('') + if not params and not no_None: + return(None) + return(params) + _dflt_ports = _getdfltport() + scheme = None + _scheme_re = re.compile('^([\w+\.-]+)(://.*)', re.IGNORECASE) + if not _scheme_re.search(orig_url): + # They probably didn't prefix a URI signifier (RFC3986 § 3.1). + # We'll add one for them. + url = 'http://' + url + scheme = 'http' + else: + # urlparse's .scheme? Total trash. + url = orig_url + scheme = _scheme_re.sub('\g<1>', orig_url) + url_split = urlparse(url) + # Get any userinfo present. + _auth = url_split.netloc.split('@')[:-1] + userinfo = _getuserinfo(_auth) + # Get any port specified (and parse the host at the same time). + if userinfo: + _h_split = url_split.netloc('@')[-1] + else: + _h_split = url_split.netloc + _nl_split = _h_split.split(':') + if len(_nl_split) > 1: + if userinfo in (None, ''): + port = int(_nl_split[1]) + host = _nl_split[0] + else: + port = int(_nl_split[-1]) + host = _nl_split[-2] + else: + if scheme in _dflt_ports: + port = _dflt_ports[scheme][0]['port'] + else: + if not no_None: + port = None + else: + '' + host = _nl_split[0] + # Split out the params, queries, fragments. + params = _subsplitter(url_split.params, ';') + queries = _subsplitter(url_split.query, '?') + fragments = _subsplitter(url_split.fragment, '#') + if url_split.path == '': + path = '/' + else: + path = os.path.dirname(url_split.path) + _dest = os.path.basename(url_split.path) + if not no_None: + dest = (None if _dest == '' else _dest) + else: + dest = _dest + url = {'scheme': scheme, + 'auth': userinfo, + 'host': host, + 'port': port, + 'path': path, + 'dest': dest, + 'params': params, + 'queries': queries, + 'fragments': fragments, + 'url': orig_url} + url['full_url'] = '{scheme}://' + if userinfo not in (None, ''): + url['full_url'] += '{user}:{password}@'.format(userinfo) + url['full_url'] += host + if port not in (None, ''): + url['full_url'] += ':{0}'.format(port) + url['full_url'] += path + dest + # Do these need to be in a specific order? + if params not in (None, ''): + _p = ['{0}={1}'.format(k, v) for k, v in params.items()] + url['full_url'] += ';{0}'.format(';'.join(_p)) + if queries not in (None, ''): + _q = ['{0}={1}'.format(k, v) for k, v in queries.items()] + url['full_url'] += '?{0}'.format('?'.join(_q)) + if fragments not in (None, ''): + _f = ['{0}={1}'.format(k, v) for k, v in fragments.items()] + url['full_url'] += '#{0}'.format('#'.join(_f)) + return(url) + +class valid(object): + def __init__(self): + pass + + def dns(self, addr): + pass + + def connection(self, conninfo): + # conninfo should ideally be (host, port) + pass + + def email(self, addr): + if isinstance(validators.email(emailparse(addr)[1]), + validators.utils.ValidationFailure): + return(False) + else: + return(True) + return() + + def gpgkeyID(self, key_id): + # Condense fingerprints into normalized 40-char "full" key IDs. + key_id = re.sub('\s+', '', key_id) + _re_str = ('^(0x)?(' + '[{HEX}]{{40}}|' + '[{HEX}]{{16}}|' + '[{HEX}]{{8}}' + ')$').format(HEX = string.hexdigits) + _key_re = re.compile(_re_str) + if not _key_re.search(key_id): + return(False) + return(True) + + def integer(self, num): + try: + int(num) + return(True) + except ValueError: + return(False) + return() + + def password(self, passwd): + # https://en.wikipedia.org/wiki/ASCII#Printable_characters + # https://serverfault.com/a/513243/103116 + _chars = ('!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ ') + for char in passwd: + if char not in _chars: + return(False) + return(True) + + def password_hash(self, passwd_hash, algo = None): + # We need to identify the algorithm if it wasn't provided. + if not algo: + # The following are supported on GNU/Linux. + # "des_crypt" is glibc's crypt() (man 3 crypt). + # https://passlib.readthedocs.io/en/stable/lib/passlib.context.html + # Specifically, ...#passlib.context.CryptContext.identify + _ctx = cryptctx(schemes = passlib_schemes) + _algo = _ctx.identify(passwd_hash) + if not _algo: + return(False) + else: + algo = re.sub('_crypt$', '', _algo) + _ctx = cryptctx(schemes = ['{0}_crypt'.format(algo)]) + if not _ctx.identify(passwd_hash): + return(False) + return(True) + + def salt_hash(self, salthash): + _idents = ''.join([i.ident for i in crypt_map if i.ident]) + _regex = re.compile('^(\$[{0}]\$)?[./0-9A-Za-z]{0,16}\$?'.format( + _idents)) + if not regex.search(salthash): + return(False) + return(True) + + def posix_filename(self, fname): + # Note: 2009 spec of POSIX, "3.282 Portable Filename Character Set" + if len(fname) == 0: + return(False) + _chars = (string.ascii_letters + string.digits + '.-_') + for char in fname: + if char not in _chars: + return(False) + return(True) + + def url(self, url): + if not re.search('^[\w+\.-]+://', url): + # They probably didn't prefix a URI signifier (RFC3986 § 3.1). + # We'll add one for them. + url = 'http://' + url + if isinstance(validators.url(url), validators.utils.ValidationFailure): + return(False) + else: + return(True) + return() + + def username(self, uname): + # https://unix.stackexchange.com/a/435120/284004 + _regex = re.compile('^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}\$)$') + if not _regex.search(uname): + return(False) + return(True) + + def uuid(self, uuid_str): + is_uuid = True + try: + u = uuid.UUID(uuid_in) + except ValueError: + return(False) + if not uuid_in == str(u): + return(False) + return(is_uuid) diff --git a/bin/bdisk.py b/bin/bdisk.py new file mode 100644 index 0000000..993f3ec --- /dev/null +++ b/bin/bdisk.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3.6 + +# PLACEHOLDER - this will be a thin wrapper installed to /usr/bin/bdisk. +import argparse +import bdisk diff --git a/bin/bdiskcfg.py b/bin/bdiskcfg.py new file mode 100644 index 0000000..afb22db --- /dev/null +++ b/bin/bdiskcfg.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3.6 + +import argparse +import bdisk.confgen as confgen diff --git a/docs/examples/.gitignore b/docs/examples/.gitignore deleted file mode 100644 index 6ac97c8..0000000 --- a/docs/examples/.gitignore +++ /dev/null @@ -1 +0,0 @@ -regen_multi.py diff --git a/docs/examples/multi_profile.xml b/docs/examples/multi_profile.xml index 49f9162..b46ce87 100644 --- a/docs/examples/multi_profile.xml +++ b/docs/examples/multi_profile.xml @@ -1,4 +1,4 @@ - + @@ -19,7 +19,7 @@ https://domain.tld/projname 1.0.0 - + 5 @@ -30,7 +30,7 @@ {xpath_ref%//meta/names/uxname/text()} - {xpath_ref%//meta/dev/author/text()} + {xpath_ref%//meta/dev/author/text()} testpassword @@ -43,15 +43,15 @@ http://archlinux.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-x86_64.tar.gz - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt - {xpath_ref%../tarball/text()}.sig + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-x86_64\.tar\.gz} + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt + {xpath_ref%../tarball/text()}.sig http://archlinux32.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-i686.tar.gz - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/sha512sums.txt + {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-i686\.tar\.gz} + cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e {xpath_ref%../tarball/text()}.sig @@ -59,6 +59,7 @@ /var/tmp/{xpath_ref%//meta/names/uxname/text()} /var/tmp/chroots/{xpath_ref%//meta/names/uxname/text()} + {xpath_ref%../cache/text()}/overlay ~/{xpath_ref%//meta/names/uxname/text()}/templates /mnt/{xpath_ref%//meta/names/uxname/text()} ~/{xpath_ref%//meta/names/uxname/text()}/distros @@ -74,7 +75,7 @@ - {xpath_ref%build/paths/ssl/text()}/ca.crt + {xpath_ref%//build/paths/ssl/text()}/ca.crt - {xpath_ref%build/paths/ssl/text()}/ca.key + {xpath_ref%//build/paths/ssl/text()}/ca.key domain.tld XX @@ -90,13 +91,13 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key domain.tld (client) XX @@ -104,11 +105,11 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%meta/dev/website/text()}/ipxe + {xpath_ref%//meta/dev/website/text()}/ipxe @@ -145,23 +146,23 @@ atotallyinsecurepassword testuser - Test User - testpassword + Test User + atestpassword http://archlinux.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-x86_64.tar.gz - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt - {xpath_ref%../tarball/text()}.sig + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-x86_64\.tar\.gz} + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt + {xpath_ref%../tarball/text()}.sig http://archlinux32.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-i686.tar.gz - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/sha512sums.txt + {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-i686\.tar\.gz} + cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e {xpath_ref%../tarball/text()}.sig @@ -169,6 +170,7 @@ /var/tmp/{xpath_ref%//meta/names/uxname/text()} /var/tmp/chroots/{xpath_ref%//meta/names/uxname/text()} + {xpath_ref%../cache/text()}/overlay ~/{xpath_ref%//meta/names/uxname/text()}/templates /mnt/{xpath_ref%//meta/names/uxname/text()} ~/{xpath_ref%//meta/names/uxname/text()}/distros @@ -183,9 +185,9 @@ - {xpath_ref%build/paths/ssl/text()}/ca.crt + {xpath_ref%//build/paths/ssl/text()}/ca.crt - {xpath_ref%build/paths/ssl/text()}/ca.key + {xpath_ref%//build/paths/ssl/text()}/ca.key domain.tld XX @@ -193,13 +195,13 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key domain.tld (client) XX @@ -207,11 +209,11 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%meta/dev/website/text()}/ipxe + {xpath_ref%//meta/dev/website/text()}/ipxe diff --git a/docs/examples/regen_multi.py b/docs/examples/regen_multi.py index c7a846a..ff3cc50 100755 --- a/docs/examples/regen_multi.py +++ b/docs/examples/regen_multi.py @@ -33,8 +33,8 @@ for e in meta.iter(): accounts_tags = {'rootpass': 'atotallyinsecurepassword', 'username': 'testuser', - 'name': 'Test User', - 'passowrd': 'atestpassword'} + 'comment': 'Test User', + 'password': 'atestpassword'} accounts = alt_profile.xpath('/profile/accounts')[0] for e in accounts.iter(): if e.tag in accounts_tags: @@ -47,7 +47,8 @@ for e in accounts.iter(): accounts.remove(accounts[2]) xml.append(alt_profile) -#print(etree.tostring(xml).decode('utf-8')) with open('multi_profile.xml', 'wb') as f: - f.write(b'\n' + etree.tostring(xml, - pretty_print = True)) + f.write(etree.tostring(xml, + pretty_print = True, + encoding = 'UTF-8', + xml_declaration = True)) diff --git a/docs/examples/single_profile.xml b/docs/examples/single_profile.xml index e74e3af..465a737 100644 --- a/docs/examples/single_profile.xml +++ b/docs/examples/single_profile.xml @@ -19,7 +19,7 @@ https://domain.tld/projname 1.0.0 - + 5 @@ -30,7 +30,7 @@ {xpath_ref%//meta/names/uxname/text()} - {xpath_ref%//meta/dev/author/text()} + {xpath_ref%//meta/dev/author/text()} testpassword @@ -47,16 +47,17 @@ http://archlinux.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-x86_64.tar.gz - {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-x86_64\.tar\.gz} + {xpath_ref%../mirror/text()}{xpath_ref%../webroot/text()}/sha1sums.txt {xpath_ref%../tarball/text()}.sig + keyserver="hkp://pool.sks-keyservers.net" + flags="latest">{xpath_ref%../tarball/text()}.sig http://archlinux32.mirror.domain.tld /iso/latest - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/archlinux-bootstrap-*-i686.tar.gz - {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/sha512sums.txt + {xpath_ref%../mirror/text()}/{xpath_ref%../webroot/text()}/{regex%archlinux-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-i686\.tar\.gz} + cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e {xpath_ref%../tarball/text()}.sig @@ -65,6 +66,7 @@ /var/tmp/{xpath_ref%//meta/names/uxname/text()} /var/tmp/chroots/{xpath_ref%//meta/names/uxname/text()} + {xpath_ref%../cache/text()}/overlay ~/{xpath_ref%//meta/names/uxname/text()}/templates /mnt/{xpath_ref%//meta/names/uxname/text()} ~/{xpath_ref%//meta/names/uxname/text()}/distros @@ -80,7 +82,7 @@ - {xpath_ref%build/paths/ssl/text()}/ca.crt + {xpath_ref%//build/paths/ssl/text()}/ca.crt - {xpath_ref%build/paths/ssl/text()}/ca.key + {xpath_ref%//build/paths/ssl/text()}/ca.key domain.tld XX @@ -96,13 +98,13 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.crt - {xpath_ref%build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key + {xpath_ref%//build/paths/ssl/text()}/{xpath_ref%//meta/names/uxname/text()}.key domain.tld (client) XX @@ -110,11 +112,11 @@ Some State Some Org, Inc. Department Name - {xpath_ref%../../../../../../meta/names/dev/email/text()} + {xpath_ref%//meta/dev/email/text()} - {xpath_ref%meta/dev/website/text()}/ipxe + {xpath_ref%//meta/dev/website/text()}/ipxe