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>(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/projname1.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 @@
@@ -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.keydomain.tldXX
@@ -90,13 +91,13 @@
Some StateSome 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()}.keydomain.tld (client)XX
@@ -104,11 +105,11 @@
Some StateSome 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 @@
atotallyinsecurepasswordtestuser
- Test User
- testpassword
+ Test User
+ atestpassword
@@ -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.keydomain.tldXX
@@ -193,13 +195,13 @@
Some StateSome 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()}.keydomain.tld (client)XX
@@ -207,11 +209,11 @@
Some StateSome 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/projname1.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 @@
@@ -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.keydomain.tldXX
@@ -96,13 +98,13 @@
Some StateSome 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()}.keydomain.tld (client)XX
@@ -110,11 +112,11 @@
Some StateSome 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