diff --git a/aif.xsd b/aif.xsd index 2417161..af3d822 100644 --- a/aif.xsd +++ b/aif.xsd @@ -477,11 +477,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + - + + + @@ -587,27 +613,30 @@ - + + + + + + + + + + + - - - - - - - - - - - + + @@ -621,6 +650,11 @@ + + + + + @@ -961,8 +995,9 @@ - + default="false"/> + @@ -976,7 +1011,21 @@ - + + + + + + + + + diff --git a/aif/disk/__init__.py b/aif/disk/__init__.py index 400f1bc..d069b78 100644 --- a/aif/disk/__init__.py +++ b/aif/disk/__init__.py @@ -27,3 +27,5 @@ try: from . import mdadm except ImportError: from . import mdadm_fallback as mdadm + +from . import main diff --git a/aif/disk/main.py b/aif/disk/main.py new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/aif/disk/main.py @@ -0,0 +1 @@ +# TODO diff --git a/aif/network/__init__.py b/aif/network/__init__.py index e419203..648f978 100644 --- a/aif/network/__init__.py +++ b/aif/network/__init__.py @@ -2,7 +2,7 @@ from . import _common from . import netctl from . import networkd from . import networkmanager -from . import net +from . import main # No longer necessary: # try: diff --git a/aif/network/net.py b/aif/network/main.py similarity index 100% rename from aif/network/net.py rename to aif/network/main.py diff --git a/aif/network/netctl.py b/aif/network/netctl.py index 5df977d..93ed895 100644 --- a/aif/network/netctl.py +++ b/aif/network/netctl.py @@ -3,10 +3,10 @@ import io import os ## import aif.utils -import aif.network._common +from . import _common -class Connection(aif.network._common.BaseConnection): +class Connection(_common.BaseConnection): def __init__(self, iface_xml): super().__init__(iface_xml) # TODO: disabling default route is not supported in-band. @@ -29,7 +29,7 @@ class Connection(aif.network._common.BaseConnection): def _initCfg(self): if self.device == 'auto': - self.device = aif.network._common.getDefIface(self.connection_type) + self.device = _common.getDefIface(self.connection_type) self.desc = ('A {0} profile for {1} (generated by AIF-NG)').format(self.connection_type, self.device) self._cfg = configparser.ConfigParser() @@ -288,11 +288,11 @@ class Wireless(Connection): except AttributeError: bssid = None if bssid: - bssid = aif.network._common.canonizeEUI(bssid) + bssid = _common.canonizeEUI(bssid) self._cfg['BASE']['AP'] = bssid crypto = self.xml.find('encryption') if crypto: - crypto = aif.network._common.convertWifiCrypto(crypto, self.xml.attrib['essid']) + crypto = _common.convertWifiCrypto(crypto, self.xml.attrib['essid']) # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): if crypto['type'] in ('wpa', 'wpa2'): # TODO: WPA2 enterprise diff --git a/aif/network/networkd.py b/aif/network/networkd.py index 5c7a291..791e759 100644 --- a/aif/network/networkd.py +++ b/aif/network/networkd.py @@ -6,10 +6,10 @@ import os import jinja2 ## import aif.utils -import aif.network._common +from . import _common -class Connection(aif.network._common.BaseConnection): +class Connection(_common.BaseConnection): def __init__(self, iface_xml): super().__init__(iface_xml) self.provider_type = 'systemd-networkd' @@ -36,7 +36,7 @@ class Connection(aif.network._common.BaseConnection): def _initCfg(self): if self.device == 'auto': - self.device = aif.network._common.getDefIface(self.connection_type) + self.device = _common.getDefIface(self.connection_type) self._cfg = {'Match': {'Name': self.device}, 'Network': {'Description': ('A {0} profile for {1} ' '(generated by AIF-NG)').format(self.connection_type, @@ -137,12 +137,12 @@ class Wireless(Connection): except AttributeError: bssid = None if bssid: - bssid = aif.network._common.canonizeEUI(bssid) + bssid = _common.canonizeEUI(bssid) self._wpasupp['bssid'] = bssid self._wpasupp['bssid_whitelist'] = bssid crypto = self.xml.find('encryption') if crypto: - crypto = aif.network._common.convertWifiCrypto(crypto, self._cfg['BASE']['ESSID']) + crypto = _common.convertWifiCrypto(crypto, self._cfg['BASE']['ESSID']) # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): # TODO: WPA2 enterprise if crypto['type'] in ('wpa', 'wpa2'): diff --git a/aif/network/networkmanager.py b/aif/network/networkmanager.py index 1eaf2bd..7a99271 100644 --- a/aif/network/networkmanager.py +++ b/aif/network/networkmanager.py @@ -4,10 +4,10 @@ import os import uuid ## import aif.utils -import aif.network._common +from . import _common -class Connection(aif.network._common.BaseConnection): +class Connection(_common.BaseConnection): def __init__(self, iface_xml): super().__init__(iface_xml) self.provider_type = 'NetworkManager' @@ -27,7 +27,7 @@ class Connection(aif.network._common.BaseConnection): def _initCfg(self): if self.device == 'auto': - self.device = aif.network._common.getDefIface(self.connection_type) + self.device = _common.getDefIface(self.connection_type) self._cfg = configparser.ConfigParser() self._cfg.optionxform = str self._cfg['connection'] = {'id': self.id, @@ -138,14 +138,14 @@ class Wireless(Connection): except AttributeError: bssid = None if bssid: - bssid = aif.network._common.canonizeEUI(bssid) + bssid = _common.canonizeEUI(bssid) self._cfg['wifi']['bssid'] = bssid self._cfg['wifi']['seen-bssids'] = '{0};'.format(bssid) crypto = self.xml.find('encryption') if crypto: self.packages.add('wpa_supplicant') self._cfg['wifi-security'] = {} - crypto = aif.network._common.convertWifiCrypto(crypto, self._cfg['wifi']['ssid']) + crypto = _common.convertWifiCrypto(crypto, self._cfg['wifi']['ssid']) # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): if crypto['type'] in ('wpa', 'wpa2'): # TODO: WPA2 enterprise diff --git a/aif/system/locales.py b/aif/system/locales.py index 4797a59..2d85d39 100644 --- a/aif/system/locales.py +++ b/aif/system/locales.py @@ -5,7 +5,7 @@ import re import shutil import subprocess - +# TODO: time _locale_re = re.compile(r'^(?!#\s|)$') _locale_def_re = re.compile(r'([^.]*)[^@]*(.*)') diff --git a/aif/system/main.py b/aif/system/main.py new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/aif/system/main.py @@ -0,0 +1 @@ +# TODO diff --git a/aif/system/users.py b/aif/system/users.py index 244d90d..e01168e 100644 --- a/aif/system/users.py +++ b/aif/system/users.py @@ -4,22 +4,317 @@ # https://unix.stackexchange.com/a/153227/284004 # https://wiki.archlinux.org/index.php/users_and_groups#File_list +import datetime import os +import re +import warnings ## -import passlib # validate password hash/gen hash +import passlib.context +import passlib.hash +## +import aif.utils + + +_skipline_re = re.compile(r'^\s*(#|$)') +_now = datetime.datetime.utcnow() +_epoch = datetime.datetime.fromtimestamp(0) +_since_epoch = _now - _epoch + + +class Group(object): + def __init__(self, group_xml): + self.xml = group_xml + self.name = None + self.gid = None + self.password = None + self.create = False + self.members = set() + if self.xml: + self.name = self.xml.attrib['name'] + self.gid = self.xml.attrib.get('gid') + self.password = self.xml.attrib.get('password', 'x') + self.create = aif.utils.xmlBool(self.xml.attrib.get('create', 'false')) + if self.gid: + self.gid = int(self.gid) + else: + if not self.password: + self.password = 'x' class Password(object): - def __init__(self): - pass + def __init__(self, password_xml): + self.xml = password_xml + self.disabled = False + self.password = None + self.hash = None + self.hash_type = None + self.hash_rounds = None + self._pass_context = passlib.context.CryptContext(schemes = ['sha512_crypt', 'sha256_crypt', 'md5_crypt']) + if self.xml: + self.disabled = aif.utils.xmlBool(self.xml.attrib.get('locked', 'false')) + self._password_xml = self.xml.xpath('passwordPlain|passwordHash') + if self._password_xml: + self._password_xml = self._password_xml[0] + if self._password_xml.tag == 'passwordPlain': + self.password = self._password_xml.text + self.hash_type = self._password_xml.attrib.get('hashType', 'sha512') + # 5000 rounds is the crypt(3) default. + self.hash_rounds = int(self._password_xml.get('rounds', 5000)) + self._pass_context.update(default = '{0}_crypt'.format(self.hash_type)) + self.hash = passlib.hash.sha512_crypt.using(rounds = self.hash_rounds).hash(self.password) + else: + self.hash = self._password_xml.text + self.hash_type = self._password_xml.attrib.get('hashType', '(detect)') + if self.hash_type == '(detect)': + self.detectHashType() + else: + self.disabled = True + self.hash = '' - -class RootUser(object): - def __init__(self): - pass + def detectHashType(self): + if self.hash.startswith(('!', 'x')): + self.disabled = True + self.hash = re.sub(r'^[!x]+', '', self.hash) + self.hash_type = re.sub(r'_crypt$', '', self._pass_context.identify(self.hash)) + if not self.hash_type: + warnings.warn('Could not determine hash type') + return() class User(object): - def __init__(self): - pass + def __init__(self, user_xml): + self.xml = user_xml + self.name = None + self.uid = None + self.gid = None + self.primary_group = None + self.password = None + self.sudo = None + self.comment = None + self.shell = None + self.minimum_age = None + self.maximum_age = None + self.warning_period = None + self.inactive_period = None + self.expire_date = None + self.groups = [] + self.passwd_entry = [] + self.shadow_entry = [] + self._initVals() + def _initVals(self): + if isinstance(self, RootUser) or not self.xml: + # We manually assign these. + return() + self.name = self.xml.attrib['name'] + self.password = Password(self.xml.find('password')) + self.sudo = aif.utils.xmlBool(self.xml.attrib.get('sudo', 'false')) + self.home = self.xml.attrib.get('home', '/home/{0}'.format(self.name)) + self.uid = self.xml.attrib.get('uid') + if self.uid: + self.uid = int(self.uid) + self.primary_group = Group(None) + self.primary_group.name = self.xml.attrib.get('group', self.name) + self.primary_group.gid = self.xml.attrib.get('gid') + if self.primary_group.gid: + self.primary_group.gid = int(self.primary_group.gid) + self.primary_group.create = True + self.primary_group.members.add(self.name) + self.shell = self.xml.attrib.get('shell', '/bin/bash') + self.comment = self.xml.attrib.get('comment') + self.minimum_age = int(self.xml.attrib.get('minAge', 0)) + self.maximum_age = int(self.xml.attrib.get('maxAge', 0)) + self.warning_period = int(self.xml.attrib.get('warnDays', 0)) + self.inactive_period = int(self.xml.attrib.get('inactiveDays', 0)) + self.expire_date = self.xml.attrib.get('expireDate') + self.last_change = _since_epoch.days - 1 + if self.expire_date: + # https://www.w3.org/TR/xmlschema-2/#dateTime + try: + self.expire_date = datetime.datetime.fromtimestamp(int(self.expire_date)) # It's an Epoch + except ValueError: + self.expire_date = re.sub(r'^[+-]', '', self.expire_date) # Strip the useless prefix + # Combine the offset into a strftime/strptime-friendly offset + self.expire_date = re.sub(r'([+-])([0-9]{2}):([0-9]{2})$', r'\g<1>\g<2>\g<3>', self.expire_date) + _common = '%Y-%m-%dT%H:%M:%S' + for t in ('{0}%z'.format(_common), '{0}Z'.format(_common), '{0}.%f%z'.format(_common)): + try: + self.expire_date = datetime.datetime.strptime(self.expire_date, t) + break + except ValueError: + continue + for group_xml in self.xml.findall('xGroup'): + g = Group(group_xml) + g.members.add(self.name) + self.groups.append(g) + return() + + def genShadow(self): + if not all((self.uid, self.gid)): + raise RuntimeError(('User objects must have a UID and GID set before their ' + 'passwd/shadow entries can be generated')) + if isinstance(self, RootUser): + # This is handled manually. + return() + # passwd(5) + self.passwd_entry = [self.name, # Username + 'x', # self.password.hash is not used because shadow, but this would be password + str(self.uid), # UID + str(self.gid), # GID + (self.comment if self.comment else ''), # GECOS + self.home, # Home directory + self.shell] # Shell + # shadow(5) + self.shadow_entry = [self.name, # Username + self.password.hash, # Password hash (duh) + (str(self.last_change) if self.last_change else ''), # Days since epoch last passwd change + (str(self.minimum_age) if self.minimum_age else '0'), # Minimum password age + (str(self.maximum_age) if self.maximum_age else ''), # Maximum password age + (str(self.warning_period) if self.warning_period else ''), # Passwd expiry warning period + (str(self.inactive_period) if self.inactive_period else ''), # Password inactivity period + (str(self.expire_date.timestamp()) if self.expire_date else ''), # Expiration date + ''] # "Reserved" + return() + + +class RootUser(User): + def __init__(self, rootpassword_xml): + super().__init__(None) + self.xml = rootpassword_xml + self.name = 'root' + self.password = Password(self.xml) + self.uid = 0 + self.gid = 0 + self.primary_group = Group(None) + self.primary_group.gid = 0 + self.primary_group.name = 'root' + self.home = '/root' + self.shell = '/bin/bash' + self.passwd_entry = [self.name, 'x', str(self.uid), str(self.gid), '', self.home, self.shell] + self.shadow_entry = [self.name, self.password.hash, str(_since_epoch.days - 1), '', '', '', '', '', ''] + + +class UserDB(object): + def __init__(self, chroot_base, rootpassword_xml, users_xml): + self.root = RootUser(rootpassword_xml) + self.users = [] + self.defined_groups = [] + self.sys_users = [] + self.sys_groups = [] + for user_xml in users_xml.findall('user'): + u = User(user_xml) + self.users.append(u) + self.defined_groups.append(u.primary_group) + self.defined_groups.extend(u.groups) + self.passwd_file = os.path.join(chroot_base, 'etc', 'passwd') + self.shadow_file = os.path.join(chroot_base, 'etc', 'shadow') + self.group_file = os.path.join(chroot_base, 'etc', 'group') + self.logindefs_file = os.path.join(chroot_base, 'etc', 'login.defs') + self.login_defaults = {} + self._parseLoginDefs() + self._parseShadow() + + def _parseLoginDefs(self): + with open(self.logindefs_file, 'r') as fh: + logindefs = fh.read().splitlines() + for line in logindefs: + if _skipline_re.search(line): + continue + l = [i.strip() for i in line.split(None, 1)] + if len(l) < 2: + l.append(None) + self.login_defaults[l[0]] = l[1] + # Convert to native objects + for k in ('FAIL_DELAY', 'PASS_MAX_DAYS', 'PASS_MIN_DAYS', 'PASS_WARN_AGE', 'UID_MIN', 'UID_MAX', + 'SYS_UID_MIN', 'SYS_UID_MAX', 'GID_MIN', 'GID_MAX', 'SYS_GID_MIN', 'SYS_GID_MAX', 'LOGIN_RETRIES', + 'LOGIN_TIMEOUT', 'LASTLOG_UID_MAX', 'MAX_MEMBERS_PER_GROUP', 'SHA_CRYPT_MIN_ROUNDS', + 'SHA_CRYPT_MAX_ROUNDS', 'SUB_GID_MIN', 'SUB_GID_MAX', 'SUB_GID_COUNT', 'SUB_UID_MIN', 'SUB_UID_MAX', + 'SUB_UID_COUNT'): + if k in self.login_defaults.keys(): + self.login_defaults[k] = int(self.login_defaults[k]) + for k in ('TTYPERM', ): + if k in self.login_defaults.keys(): + self.login_defaults[k] = int(self.login_defaults[k], 8) + for k in ('ERASECHAR', 'KILLCHAR', 'UMASK'): + if k in self.login_defaults.keys(): + v = self.login_defaults[k] + if v.startswith('0x'): + v = int(v, 16) + elif v.startswith('0'): + v = int(v, 8) + else: + v = int(v) + self.login_defaults[k] = v + for k in ('LOG_UNKFAIL_ENAB', 'LOG_OK_LOGINS', 'SYSLOG_SU_ENAB', 'SYSLOG_SG_ENAB', 'DEFAULT_HOME', + 'CREATE_HOME', 'USERGROUPS_ENAB', 'MD5_CRYPT_ENAB'): + if k in self.login_defaults.keys(): + v = self.login_defaults[k].lower() + self.login_defaults[k] = (True if v == 'yes' else False) + return() + + def _parseShadow(self): + def parseShadowLine(line): + shadowdict = dict(zip(['name', 'password', 'last_change', 'minimum_age', 'maximum_age', 'warning_period', + 'inactive_period', 'expire_date', 'RESERVED'], + line)) + p = Password(None) + p.hash = shadowdict['password'] + p.detectHashType() + shadowdict['password'] = p + del(shadowdict['RESERVED']) + for i in ('last_change', 'minimum_age', 'maximum_age', 'warning_period', 'inactive_period'): + if shadowdict[i].strip() == '': + shadowdict[i] = None + else: + shadowdict[i] = int(shadowdict[i]) + if shadowdict['expire_date'].strip() == '': + shadowdict['expire_date'] = None + else: + shadowdict['expire_date'] = datetime.datetime.fromtimestamp(shadowdict['expire_date']) + return(shadowdict) + + def parseUserLine(line): + userdict = dict(zip(['name', 'password', 'uid', 'gid', 'comment', 'home', 'shell'], line)) + del(userdict['password']) # We don't use this because shadow + for i in ('uid', 'gid'): + userdict[k] = int(userdict[k]) + if userdict['comment'].strip() == '': + userdict['comment'] = None + return(userdict) + + def parseGroupLine(line): + groupdict = dict(zip(['name', 'password', 'gid', 'members'], line)) + groupdict['members'] = set(','.split(groupdict['members'])) + return(groupdict) + + sys_shadow = {} + users = {} + groups = {} + for f in ('shadow', 'passwd', 'group'): + sys_shadow[f] = [] + with open(getattr(self, '{0}_file'.format(f)), 'r') as fh: + for line in fh.read().splitlines(): + if _skipline_re.search(line): + continue + sys_shadow[f].append(line.split(':')) + # TODO: iterate through sys_shadow, convert passwd + shadow into a User obj, convert group into Group objs, + # and associate between the two. might require a couple iterations... + for groupline in sys_shadow['group']: + group = parseGroupLine(groupline) + g = Group(None) + for k, v in group.items(): + setattr(g, k, v) + self.sys_groups.append(g) + groups[g.name] = g + for userline in sys_shadow['passwd']: + user = parseUserLine(userline) + users[user['name']] = user + for shadowline in sys_shadow['shadow']: + user = parseShadowLine(shadowline) + udict = users[user['name']] + udict.update(user) + u = User(None) + for k, v in udict.items(): + setattr(u, k, v) + self.sys_users.append(u) + return()