1031 lines
51 KiB
Python
Executable File
1031 lines
51 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Ironically enough, I think building a GUI for this would be *cleaner*.
|
|
# Go figure.
|
|
|
|
import datetime
|
|
import getpass
|
|
import os
|
|
import uuid
|
|
import lxml.etree
|
|
import utils # LOCAL
|
|
import confparse # LOCAL
|
|
|
|
|
|
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 password entry):\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:
|
|
_ns = {None: 'http://bdisk.square-r00t.net/',
|
|
'xsi': 'http://www.w3.org/2001/XMLSchema-instance'}
|
|
_xsi = {
|
|
'{http://www.w3.org/2001/XMLSchema-instance}schemaLocation':
|
|
'http://bdisk.square-r00t.net bdisk.xsd'}
|
|
self.cfg = lxml.etree.Element('bdisk', nsmap = _ns, attrib = _xsi)
|
|
self.append = False
|
|
self.profile = lxml.etree.Element('profile')
|
|
self.cfg.append(self.profile)
|
|
|
|
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()
|
|
self.get_build()
|
|
self.get_iso()
|
|
self.get_ipxe()
|
|
self.get_pki()
|
|
self.get_gpg()
|
|
self.get_sync()
|
|
# TODO: make this more specific (script? gui? web? etc.)
|
|
# and append comment to bdisk element
|
|
_comment = lxml.etree.Comment(
|
|
'Generated {0} by BDisk configuration generator'.format(
|
|
str(datetime.datetime.now())
|
|
)
|
|
)
|
|
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:
|
|
# this doesn't trigger? maybe?
|
|
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\t(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()))
|
|
# TODO: explicit not being set for explicitly-set sums,
|
|
# and checksum string not actually added to those. investigate.
|
|
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.attrib['hash_algo'] = checksum_type
|
|
chksum.attrib['explicit'] = "false"
|
|
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'] = "true"
|
|
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['full_url'])
|
|
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:<9} {1}'.format(
|
|
'{0}:'.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)
|
|
sources.append(source)
|
|
_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 get_build(self):
|
|
print('\n++ BUILD ++')
|
|
build = lxml.etree.SubElement(self.profile, 'build')
|
|
_chk_optimizations = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to enable experimental optimizations?\n'),
|
|
usage = (
|
|
'{0} for yes, {1} for no...\n'))
|
|
if _chk_optimizations:
|
|
build.attrib['its_full_of_stars'] = 'true'
|
|
print('\n++ BUILD || PATHS ++')
|
|
# Thankfully, we can simplify a lot of this.
|
|
_dir_strings = {'base': ('the base directory (used for files that are '
|
|
'required for basic guest environment '
|
|
'support)'),
|
|
'cache': ('the caching directory (used for temporary '
|
|
'files, temporary downloads, etc.)'),
|
|
'chroot': ('the chroot directory (where we store '
|
|
'the root filesystems that are converted '
|
|
'into the live environment'),
|
|
'overlay': ('the overlay directory (allowing for '
|
|
'injecting files into the live '
|
|
'environment\'s filesystem)'),
|
|
'templates': ('the template directory (for templating '
|
|
'configuration files in the live '
|
|
'environment)'),
|
|
'mount': ('the mount directory (where chroots are '
|
|
'mounted to perform preparation tasks)'),
|
|
'distros': ('the distro plugin directory (where '
|
|
'plugins supporting other guest Linux '
|
|
'distributions are put)'),
|
|
'dest': ('the destination directory (where finished '
|
|
'products like ISO image files go)'),
|
|
'iso': ('the iso directory (the overlay directory for '
|
|
'the "outer" layer of media)'),
|
|
'http': ('the HTTP directory (where a webroot is '
|
|
'created that can be used to serve iPXE)'),
|
|
'tftp': ('the TFTP directory (where a TFTP/'
|
|
'traditional PXE root is created)'),
|
|
'pki': ('the SSL/TLS PKI directory (where we store '
|
|
'the PKI structure we use/re-use - MAKE SURE '
|
|
'it is in a path that is well-protected!)')}
|
|
has_paths = False
|
|
# Get the paths
|
|
while not has_paths:
|
|
paths = lxml.etree.Element('paths')
|
|
_paths_elems = {}
|
|
for _dir in _dir_strings:
|
|
_paths_elems[_dir] = lxml.etree.SubElement(paths, _dir)
|
|
path = prompt.path(_dir_strings[_dir])
|
|
_paths_elems[_dir].text = path
|
|
build.append(paths)
|
|
has_paths = True
|
|
print('\n++ BUILD || ENVIRONMENT DISTRO ++')
|
|
has_distro = False
|
|
while not has_distro:
|
|
try:
|
|
distro_path = self.profile.xpath('//paths/distros/text()')[0]
|
|
except IndexError:
|
|
distro_path = 'your "distros" path'
|
|
distro = (input(('\nWhich distro plugin/distro base are you '
|
|
'using? See the manual for more information. A '
|
|
'matching plugin MUST exist in {0} for a build '
|
|
'to complete successfully! The default (Arch '
|
|
'Linux, "archlinux") will be used if left blank.'
|
|
'\nDistro base: ').format(distro_path))).strip()
|
|
if distro == '':
|
|
distro = 'archlinux'
|
|
if not valid.plugin_name(distro):
|
|
print('That is not a valid name. See the manual for examples '
|
|
'and shipped plugins. Retrying.')
|
|
continue
|
|
else:
|
|
has_distro = True
|
|
distro_elem = lxml.etree.SubElement(build, 'distro')
|
|
distro_elem.text = distro
|
|
return()
|
|
|
|
def get_iso(self):
|
|
print('\n++ ISO ++')
|
|
# We don't need to ask if it's multiarch if we only have one arch.
|
|
iso = lxml.etree.Element('iso')
|
|
_arches = []
|
|
for _source in self.profile.xpath('sources/source'):
|
|
_arches.append(_source.attrib['arch'])
|
|
if len(_arches) < 2:
|
|
iso.attrib['multi_arch'] = _arches[0]
|
|
self.profile.append(iso)
|
|
# We have more than one arch, so we need to ask how they want to handle
|
|
# it.
|
|
_ma_strings = {'true': ('a multi-arch ISO (both architectures on one '
|
|
'ISO)'),
|
|
'false': ('separate image files for '
|
|
'{0}').format(' and '.join(_arches))}
|
|
for a in _arches:
|
|
_ma_strings[a] = 'only build an image file for {0}'.format(a)
|
|
if len(_arches) > 1:
|
|
_multi_arch_input = None
|
|
while not _multi_arch_input:
|
|
print('\n++ ISO || MULTI-ARCH ++')
|
|
_multi_arch = (input((
|
|
'\nYou have defined mutliple architecture sources. BDisk '
|
|
'allows you to build an ISO image (USB image, etc.) that '
|
|
'will support both architectures using the same file. '
|
|
'Please consult the manual if you need further '
|
|
'information.\nPossible values:\n'
|
|
'\n\t{0}\n\nMulti-arch: ').format(
|
|
'\n\t'.join(
|
|
['{0}:\t{1}'.format(k, v) for k, v in _ma_strings.items()]
|
|
)))).strip().lower()
|
|
if _multi_arch not in _ma_strings.keys():
|
|
print('Invalid selection; retrying.')
|
|
continue
|
|
else:
|
|
_multi_arch_input = _multi_arch
|
|
iso.attrib['multi_arch'] = _multi_arch_input
|
|
_gpg_sign = None
|
|
while not _gpg_sign:
|
|
print('\n++ ISO || SIGNING ++')
|
|
_gpg_input = prompt.confirm_or_no(prompt = (
|
|
'\nWe can sign ISO image files using GPG (we\'ll give the '
|
|
'option to configure it a bit later).\nWould you like to sign '
|
|
'the ISO/USB image files with GPG?\n'), usage = (
|
|
'{0} for yes, {1} for no...\n'))
|
|
_gpg_sign = ('true' if _gpg_input else 'false')
|
|
iso.attrib['sign'] = _gpg_sign
|
|
self.profile.append(iso)
|
|
return()
|
|
|
|
def get_ipxe(self):
|
|
print('\n++ iPXE ++')
|
|
ipxe = lxml.etree.Element('ipxe')
|
|
_ipxe = None
|
|
while not _ipxe:
|
|
_ipxe = prompt.confirm_or_no(prompt = (
|
|
'\nBDisk has built-in support for iPXE (https://ipxe.org/, '
|
|
'see the manual for more information). Would you like to '
|
|
'build iPXE support?\n'), usage = (
|
|
'{0} for yes, {1} for no...\n'))
|
|
_ipxe = ('true' if _ipxe else 'true')
|
|
if _ipxe == 'true':
|
|
print('\n++ iPXE || MINI-ISO ++')
|
|
_iso = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to build a "mini-ISO" (see the manual) for '
|
|
'bootstrapping iPXE booting from USB or optical media?\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
ipxe.attrib['iso'] = ('true' if _iso else 'false')
|
|
print('\n++ iPXE || SIGNING ++')
|
|
_sign = prompt.confirm_or_no(prompt = (
|
|
'\nBDisk can sign the mini-ISO and other relevant files for '
|
|
'iPXE builds using GPG. Would you like to sign the iPXE build '
|
|
'distributables? (You\'ll have the chance to configure GPG '
|
|
'later).\n'), usage = ('{0} for yes, {1} for no...\n'))
|
|
ipxe.attrib['sign'] = ('true' if _sign else 'false')
|
|
_uri = None
|
|
while not _uri:
|
|
print('\n++ iPXE || URL ++')
|
|
_uri = (input(
|
|
'\niPXE uses a remote URI to boot. What URI should this '
|
|
'profile\'s iPXE use? (Consult the manual for more '
|
|
'information.)\niPXE Boot URL: ')).strip()
|
|
if not valid.url(_uri):
|
|
print('Invalid URL, retrying...')
|
|
_uri = None
|
|
continue
|
|
else:
|
|
uri = lxml.etree.SubElement(ipxe, 'uri')
|
|
uri.text = _uri
|
|
if _ipxe == 'true':
|
|
self.profile.append(ipxe)
|
|
return()
|
|
|
|
def get_pki(self):
|
|
print('\n++ SSL/TLS PKI ++')
|
|
pki = lxml.etree.Element('pki')
|
|
_pki = None
|
|
while not _pki:
|
|
_pki = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to support SSL/TLS transport for various '
|
|
'functions? Currently this is only used for iPXE, but future '
|
|
'applications may be possible.\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
if _pki:
|
|
_pki_url_chk = self.profile.xpath('ipxe/uri/text()')
|
|
_pki_url = (_pki_url_chk[0] if _pki_url_chk else None)
|
|
print('\n++ SSL/TLS PKI || OVERWRITE ++')
|
|
_overwrite = prompt.confirm_or_no(prompt = (
|
|
'\nYou\'ll have the opportunity in a moment to configure '
|
|
'paths for the various files, but do you want BDisk to '
|
|
're-generate (and thus overwrite) any of the files it finds? '
|
|
'If you use these files for anything OTHER than BDisk (or '
|
|
'wish to keep persistent keys and certs), you should '
|
|
'DEFINITELY answer no here.\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
pki.attrib['overwrite'] = ('true' if _overwrite else 'false')
|
|
for x in ('ca', 'client'):
|
|
print('\n++ SSL/TLS PKI || {0} ++'.format(x.upper()))
|
|
_x = None
|
|
while not _x:
|
|
_x = prompt.ssl_object(x, _pki_url)
|
|
elem = lxml.etree.SubElement(pki, x)
|
|
_elems = {}
|
|
for e in _x['paths']:
|
|
_elems[e] = lxml.etree.SubElement(elem, e)
|
|
_elems[e].text = _x['paths'][e]
|
|
for e in _x['attribs']:
|
|
for a in _x['attribs'][e]:
|
|
_elems[e].attrib[a] = _x['attribs'][e][a]
|
|
if _pki:
|
|
self.profile.append(pki)
|
|
return()
|
|
|
|
def get_gpg(self):
|
|
_sigchk = False
|
|
_xpaths = ['//iso/@sign', '//ipxe/@sign']
|
|
for x in _xpaths:
|
|
_x = self.profile.xpath(x)
|
|
for a in _x:
|
|
if a == 'true':
|
|
_sigchk = True
|
|
break
|
|
if _sigchk:
|
|
break
|
|
if not _sigchk:
|
|
# An empty gpg element signifies a blank configuration.
|
|
lxml.etree.SubElement(self.profile, 'gpg')
|
|
return()
|
|
gpg = lxml.etree.Element('gpg')
|
|
print('\n++ GPG ++')
|
|
_gpg = None
|
|
while not _gpg:
|
|
print('\n++ GPG || KEY ID ++')
|
|
_gpg = (input('\nYou have specified GPG signing for one or more '
|
|
'components. If you have a key already, please '
|
|
'enter the key ID here; otherwise if left blank, '
|
|
'BDisk will generate one for you.\n'
|
|
'Key ID: ')).upper().strip()
|
|
if _gpg == '':
|
|
_gpg = 'none'
|
|
else:
|
|
if not valid.gpgkeyID(_gpg):
|
|
print('That is not a valid GPG key ID. Retrying.')
|
|
continue
|
|
gpg.attrib['keyid'] = _gpg
|
|
print('\n++ GPG || GPG HOME DIRECTORY ++')
|
|
_gpghome = None
|
|
while not _gpghome:
|
|
_gpghome = (input('\nWhat directory should be used for the GnuPG '
|
|
'home directory? If left blank, BDisk will use '
|
|
'the system default (first checking for an '
|
|
'environment variable called GNUPGHOME, and '
|
|
'then trying the built-in ~/.gnupg directory).'
|
|
'\nGPG Home Directory: '))
|
|
if _gpghome.strip() != '':
|
|
gpg.attrib['gnupghome'] = _gpghome
|
|
else:
|
|
_gpghome = 'none'
|
|
print('\n++ GPG || KEYSERVER PUSHING ++')
|
|
_gpgpublish = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to push the key to the SKS keyserver pool '
|
|
'(making it much easier for end-users to look it up)?\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
gpg.attrib['publish'] = ('true' if _gpgpublish else 'false')
|
|
print('\n++ GPG || PASSWORD HANDLING ++')
|
|
_gpgpass_prompt = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like BDisk to prompt you for a passphrase? If not, '
|
|
'you\'ll either have to include the passphrase in plaintext in '
|
|
'the configuration (HIGHLY unrecommended) or use a blank '
|
|
'passphrase (also HIGHLY unrecommended).\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
gpg.attrib['prompt_passphrase'] = ('true' if _gpgpass_prompt else
|
|
'false')
|
|
_pass = None
|
|
if not _gpgpass_prompt:
|
|
while not _pass:
|
|
print('\n++ GPG || PASSPHRASE ++')
|
|
_pass = getpass.getpass((
|
|
'\nYou have specified not to use passphrase prompting for '
|
|
'GPG. As such, you will need to provide the passphrase. '
|
|
'If left blank, BDisk will assume one is not/should not '
|
|
'be set.\nPassphrase (will NOT echo back; type '
|
|
'carefully!): '))
|
|
if _pass.strip() == '':
|
|
_pass = 'none'
|
|
elif not valid.password(_pass):
|
|
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 = None
|
|
continue
|
|
else:
|
|
gpg.attrib['passphrase'] = _pass
|
|
if gpg.attrib['keyid'] == 'none':
|
|
_more_subkeys = True
|
|
while _more_subkeys:
|
|
_subkey = prompt.gpg_keygen_attribs()
|
|
subkey = lxml.etree.SubElement(gpg, 'key')
|
|
for a in _subkey['attribs']:
|
|
subkey.attrib[a] = _subkey['attribs'][a]
|
|
for e in _subkey['params']:
|
|
param = lxml.etree.SubElement(subkey, e)
|
|
param.text = _subkey['params'][e]
|
|
_more_subkeys = prompt.confirm_or_no(prompt = (
|
|
'\nDo you want to add another subkey?\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
self.profile.append(gpg)
|
|
return()
|
|
|
|
def get_sync(self):
|
|
print('\n++ SYNC ++')
|
|
print('This section will allow you to configure REMOTE paths to copy '
|
|
'the finished products to. These are COPIES, meaning they will '
|
|
'exist in the destination paths you specified earlier but will '
|
|
'also be copied to these destinations. The difference is these '
|
|
'can be on a remote host and will be copied via rsync.')
|
|
_sync_chk = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to enable remote syncing?\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
if not _sync_chk:
|
|
elem = lxml.etree.SubElement(self.profile, 'sync')
|
|
rsync = lxml.etree.SubElement(elem, 'rsync')
|
|
rsync.attrib['enabled'] = 'no'
|
|
return()
|
|
sync = lxml.etree.Element('sync')
|
|
_syncs = {'ipxe': ('the iPXE base'),
|
|
'tftp': ('the TFTP root'),
|
|
'iso': ('the ISO images destination'),
|
|
'gpg': ('the exported GPG public key')}
|
|
for s in _syncs:
|
|
print('\n++ SYNC || {0} ++'.format(s.upper()))
|
|
_item_sync_chk = prompt.confirm_or_no(prompt = (
|
|
'\nWould you like to sync {0}?\n'.format(_syncs[s])),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
elem = lxml.etree.SubElement(sync, s)
|
|
elem.attrib['enabled'] = ('true' if _item_sync_chk else 'false')
|
|
if not _item_sync_chk:
|
|
continue
|
|
if s == 'gpg':
|
|
_choices = ['ASCII', 'binary']
|
|
_export_type = (input(
|
|
('\nWhat type of export dump would you like to use '
|
|
'for the GPG public key? (You can use the first '
|
|
'letter only as an abbreviation; the default is '
|
|
'ASCII.)\n'
|
|
'Choices:\n\n\t{0}\n\nExport type: ').format(
|
|
'\n\t'.join(_choices)
|
|
))).strip().lower()
|
|
if _export_type.startswith('a'):
|
|
_export_type = 'asc'
|
|
elif _export_type.startswith('b'):
|
|
_export_type = 'bin'
|
|
else:
|
|
print('Using the default.')
|
|
_export_type = 'asc'
|
|
elem.attrib['format'] = _export_type
|
|
_path = None
|
|
while not _path:
|
|
_path = input(
|
|
('\nWhere (remote path) would you like {0} to be synced?\n'
|
|
'Path: ').format(_syncs[s]))
|
|
if _path.strip() == '':
|
|
print('Please specify a path. Retrying...')
|
|
_path = None
|
|
continue
|
|
elem.text = _path
|
|
rsync = lxml.etree.SubElement(sync, 'rsync')
|
|
print('\n++ SYNC || RSYNC ++')
|
|
_rsync = prompt.confirm_or_no(prompt = (
|
|
'\nEnable rsync? If disabled, no syncing would be done even if '
|
|
'enabled by a specific item above.\n'),
|
|
usage = ('{0} for yes, {1} for no...\n'))
|
|
if not _rsync:
|
|
self.profile.append(sync)
|
|
return()
|
|
_host = (input(
|
|
'\nWhat host should we use for rsync?\nHost: ')
|
|
).strip().lower()
|
|
if ':' in _host:
|
|
_h = _host.split(':')
|
|
_host = _h[0]
|
|
_port = _h[1]
|
|
else:
|
|
_port = None
|
|
host = lxml.etree.SubElement(rsync, 'host')
|
|
host.text = _host
|
|
while not _port:
|
|
_port = (input(
|
|
'\nWhat port should we use? (Default: 22)\nPort: ')
|
|
).strip()
|
|
if _port == '':
|
|
_port = '22'
|
|
if not valid.integer(_port):
|
|
print('Invalid port number; try again.')
|
|
_port = None
|
|
continue
|
|
port = lxml.etree.SubElement(rsync, 'port')
|
|
port.text = _port
|
|
_user = (input(
|
|
'\nWhat user should we use for {0}?\nUser: '.format(_host)
|
|
)).strip()
|
|
while not valid.username(_user):
|
|
print('Invalid username.')
|
|
_user = (input('\nUsername: ')).strip()
|
|
user = lxml.etree.SubElement(rsync, 'user')
|
|
user.text = _user
|
|
_pubkey = input('\nWhat path should we use for the SSH private key? '
|
|
'Default: ~/.ssh/id_rsa\n'
|
|
'Key path: ')
|
|
pubkey = lxml.etree.SubElement(rsync, 'pubkey')
|
|
pubkey.text = _pubkey
|
|
self.profile.append(sync)
|
|
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()
|