From 3a2eca4b9818218a8fd0b657c7a1f98f4fda88e8 Mon Sep 17 00:00:00 2001 From: brent s Date: Sat, 30 Nov 2019 01:05:20 -0500 Subject: [PATCH] i officially hate netctl now i think --- aif.xsd | 147 +++++++++++++--- aif/__init__.py | 14 ++ aif/constants_fallback.py | 1 + aif/network/__init__.py | 21 +-- aif/network/_common.py | 210 +++++++++++++++++++++++ aif/network/net.py | 18 +- aif/network/netctl.py | 277 +++++++++++++++++++++++++++++++ aif/network/networkd.py | 7 +- aif/network/networkd_fallback.py | 0 aif/network/networkmanager.py | 165 +++++++++--------- aif/utils.py | 18 ++ docs/MANUAL.adoc | 20 +++ examples/aif.xml | 24 ++- 13 files changed, 790 insertions(+), 132 deletions(-) delete mode 100644 aif/network/networkd_fallback.py diff --git a/aif.xsd b/aif.xsd index ede6290..54dd294 100644 --- a/aif.xsd +++ b/aif.xsd @@ -169,11 +169,11 @@ - + + - - + @@ -187,11 +187,11 @@ - + + - - + @@ -205,12 +205,21 @@ - + + + + + + + + + - - - + + + + + @@ -223,6 +232,14 @@ + + + + + + + + @@ -245,7 +262,7 @@ - + @@ -254,6 +271,7 @@ + @@ -266,7 +284,7 @@ - + @@ -275,6 +293,8 @@ + @@ -288,10 +308,85 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - @@ -323,7 +418,7 @@ - + @@ -331,14 +426,14 @@ - + - - + + @@ -351,7 +446,7 @@ - + @@ -369,7 +464,7 @@ - + @@ -783,7 +878,6 @@ - @@ -793,7 +887,8 @@ - + + diff --git a/aif/__init__.py b/aif/__init__.py index 5e8d022..bb5745b 100644 --- a/aif/__init__.py +++ b/aif/__init__.py @@ -15,4 +15,18 @@ from . import pacman class AIF(object): def __init__(self): + # Process: + # 0.) get config (already initialized at this point) + # 1.) run pre scripts* + # 2.) initialize all objects' classes + # 3.) disk ops = partition, mount* + # 3.) b.) "pivot" logging here. create /root/aif/ and copy log to /root/aif/aif.log, use that + # as new log file. copy over scripts. + # 4.) install base system* + # 4.) b.) other system.* tasks. locale(s), etc.* + # 5.) run pkg scripts* + # 6.) install kernel(?), pkg items* + # 6.) b.) remember to install the .packages items for each object + # 7.) write out confs and other object application methods* + # * = log but don't do anything for dryrun pass diff --git a/aif/constants_fallback.py b/aif/constants_fallback.py index 2217f03..f9672b4 100644 --- a/aif/constants_fallback.py +++ b/aif/constants_fallback.py @@ -10,6 +10,7 @@ VERSION = '0.2.0' # blkinfo, mdstat, and pyparted are only needed for the non-gi fallbacks. EXTERNAL_DEPS = ['blkinfo', 'gpg', + 'jinja2', 'lxml', 'mdstat', 'parse', diff --git a/aif/network/__init__.py b/aif/network/__init__.py index bad57e3..e419203 100644 --- a/aif/network/__init__.py +++ b/aif/network/__init__.py @@ -1,19 +1,20 @@ +from . import _common +from . import netctl +from . import networkd +from . import networkmanager +from . import net + +# No longer necessary: # try: # from . import _common # except ImportError: # pass # GI isn't supported, so we don't even use a fallback. -from . import netctl - -# TODO: use DBus interface for systemd but fallback to subprocess? # http://0pointer.net/blog/the-new-sd-bus-api-of-systemd.html # https://www.youtube.com/watch?v=ZUX9Fx8Rwzg # https://www.youtube.com/watch?v=lBQgMGPxqNo # https://github.com/facebookincubator/pystemd has some unit/service examples -try: - from . import networkd -except ImportError: - from . import networkd_fallback as networkd - -from . import networkmanager -from . import net +# try: +# from . import networkd +# except ImportError: +# from . import networkd_fallback as networkd diff --git a/aif/network/_common.py b/aif/network/_common.py index 8d87a29..9d7312c 100644 --- a/aif/network/_common.py +++ b/aif/network/_common.py @@ -1,3 +1,213 @@ +import ipaddress +import os +import pathlib +import re +## +from pyroute2 import IPDB +## +import aif.utils + +# Not needed # import gi # gi.require_version('NM', '1.0') # from gi.repository import GObject, NM, GLib + + +def canonizeEUI(phyaddr): + # The easy transformations first. + phyaddr = re.sub(r'[.:-]', '', phyaddr.upper().strip()) + eui = ':'.join(['{0}'.format(phyaddr[i:i+2]) for i in range(0, 12, 2)]) + return(eui) + + +def convertIpTuples(addr_xmlobj): + # These tuples follow either: + # ('dhcp'/'dhcp6'/'slaac', None, None) for auto configuration + # (ipaddress.IPv4/6Address(IP), CIDR, ipaddress.IPv4/6Address(GW)) for static configuration + if addr_xmlobj.text in ('dhcp', 'dhcp6', 'slaac'): + addr = addr_xmlobj.text.strip() + net = None + gw = None + else: + components = addr_xmlobj.text.strip().split('/') + if len(components) > 2: + raise ValueError('Invalid IP/CIDR format: {0}'.format(addr_xmlobj.text)) + if len(components) == 1: + addr = ipaddress.ip_address(components[0]) + if addr.version == 4: + components.append('24') + elif addr.version == 6: + components.append('64') + addr = ipaddress.ip_address(components[0]) + net = ipaddress.ip_network('/'.join(components), strict = False) + try: + gw = ipaddress.ip_address(addr_xmlobj.attrib.get('gateway').strip()) + except (ValueError, AttributeError): + gw = next(net.hosts()) + return((addr, net, gw)) + + +def convertWifiCrypto(crypto_xmlobj): + crypto = {'type': crypto_xmlobj.find('type').text.strip()} + # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): + if crypto['type'] in ('wpa', 'wpa2'): + crypto['mode'] = crypto_xmlobj.find('mode') + if not crypto['mode']: + crypto['mode'] = 'personal' + else: + crypto['mode'] = crypto['mode'].text.strip() + else: + crypto['mode'] = None + creds = crypto_xmlobj.find('creds') + crypto['auth'] = {'type': creds.attrib.get('type', 'psk').strip()} + if crypto['auth']['type'] == 'psk': + crypto['auth']['psk'] = creds.text + # TODO: enterprise support + return(crypto) + + +def getDefIface(ifacetype): + if ifacetype == 'ethernet': + if isNotPersistent(): + prefix = 'eth' + else: + prefix = 'en' + elif ifacetype == 'wireless': + prefix = 'wl' + else: + raise ValueError('ifacetype must be one of "ethernet" or "wireless"') + ifname = None + with IPDB() as ipdb: + for iface in ipdb.interfaces.keys(): + if iface.startswith(prefix): + ifname = iface + break + if not ifname: + return(None) + return(ifname) + + +def isNotPersistent(chroot_base = '/'): + chroot_base = pathlib.Path(chroot_base) + systemd_override = chroot_base.joinpath('etc', + 'systemd', + 'network', + '99-default.link') + kernel_cmdline = chroot_base.joinpath('proc', 'cmdline') + devnull = chroot_base.joinpath('dev', 'null') + rootdevnull = pathlib.PosixPath('/dev/null') + if os.path.islink(systemd_override) and pathlib.Path(systemd_override).resolve() in (devnull, rootdevnull): + return(True) + cmds = aif.utils.kernelCmdline(chroot_base) + if 'net.ifnames' in cmds.keys() and cmds['net.ifnames'] == '0': + return(True) + return(False) + + +class BaseConnection(object): + def __init__(self, iface_xml): + self.xml = iface_xml + self.id = self.xml.attrib['id'].strip() + self.device = self.xml.attrib['device'].strip() + self.is_defroute = aif.utils.xmlBool(self.xml.attrib.get('defroute', 'false').strip()) + try: + self.domain = self.xml.attrib.get('searchDomain').strip() + except AttributeError: + self.domain = None + self.dhcp_client = self.xml.attrib.get('dhcpClient', 'dhcpcd').strip() + self._cfg = None + self.connection_type = None + self.provider_type = None + self.packages = [] + self.services = {} + self.resolvers = [] + self.addrs = {'ipv4': [], + 'ipv6': []} + self.routes = {'ipv4': [], + 'ipv6': []} + self.auto = {} + for x in ('resolvers', 'routes', 'addresses'): + self.auto[x] = {} + x_xml = self.xml.find(x) + for t in ('ipv4', 'ipv6'): + if t == 'ipv6' and x == 'addresses': + self.auto[x][t] = 'slaac' + else: + self.auto[x][t] = True + if x_xml: + t_xml = x_xml.find(t) + if t_xml: + if t == 'ipv6' and x == 'addresses': + a = t_xml.attrib.get('auto', 'slaac').strip() + if a.lower() in ('false', '0', 'none'): + self.auto[x][t] = False + else: + self.auto[x][t] = a + else: + self.auto[x][t] = aif.utils.xmlBool(t_xml.attrib.get('auto', 'true').strip()) + # These defaults are from the man page. However, we might want to add: + # domain-search, netbios-scope, interface-mtu, rfc3442-classless-static-routes, ntp-servers, + # dhcp6.fqdn, dhcp6.sntp-servers + # under requests and for requires, maybe: + # routers, domain-name-servers, domain-name, domain-search, host-name + self.dhcp_defaults = { + 'dhclient': {'requests': {'ipv4': ('subnet-mask', 'broadcast-address', 'time-offset', 'routers', + 'domain-name', 'domain-name-servers', 'host-name'), + 'ipv6': ('dhcp6.name-servers', + 'dhcp6.domain-search')}, + 'requires': {'ipv4': tuple(), + 'ipv6': tuple()}}, + 'dhcpcd': {'default_opts': ('hostname', 'duid', 'persistent', 'slaac private', 'noipv4ll'), + # dhcpcd -V to display variables. + # "option ", prepend "dhcp6_" for ipv6. if no ipv6 opts present, same are mapped to ipv6. + # But we explicitly add them for munging downstream. + 'requests': {'ipv4': ('rapid_commit', 'domain_name_servers', 'domain_name', 'domain_search', + 'host_name', 'classless_static_routes', 'interface_mtu'), + 'ipv6': ('dhcp6_rapid_commit', 'dhcp6_domain_name_servers', 'dhcp6_domain_name', + 'dhcp6_domain_search', 'dhcp6_host_name', 'dhcp6_classless_static_routes', + 'dhcp6_interface_mtu')}, + # "require " + 'requires': {'ipv4': ('dhcp_server_identifier', ), + 'ipv6': tuple()}}} + self._initAddrs() + self._initResolvers() + self._initRoutes() + + def _initAddrs(self): + for addrtype in ('ipv4', 'ipv6'): + for a in self.xml.findall('addresses/{0}/address'.format(addrtype)): + addrset = convertIpTuples(a) + if addrset not in self.addrs[addrtype]: + self.addrs[addrtype].append(addrset) + return() + + def _initCfg(self): + # A dummy method; this is overridden by the subclasses. + # It's honestly here to make my IDE stop complaining. :) + pass + return() + + def _initConnCfg(self): + # A dummy method; this is overridden by the subclasses. + # It's honestly here to make my IDE stop complaining. :) + pass + return() + + def _initResolvers(self): + resolvers_xml = self.xml.find('resolvers') + if resolvers_xml: + for r in resolvers_xml.findall('resolver'): + resolver = ipaddress.ip_address(r.text.strip()) + if resolver not in self.resolvers: + self.resolvers.append(resolver) + return() + + def _initRoutes(self): + routes_xml = self.xml.find('routes') + if routes_xml: + for addrtype in ('ipv4', 'ipv6'): + for a in self.xml.findall('routes/{0}/route'.format(addrtype)): + addrset = convertIpTuples(a) + if addrset not in self.routes[addrtype]: + self.routes[addrtype].append(addrset) + return() diff --git a/aif/network/net.py b/aif/network/net.py index 11938df..622162b 100644 --- a/aif/network/net.py +++ b/aif/network/net.py @@ -1,8 +1,11 @@ +import os + + class Network(object): def __init__(self, network_xml): self.xml = network_xml - self.hostname = self.xml.attrib['hostname'] - self.provider = self.xml.attrib.get('provider', 'netctl') + self.hostname = self.xml.attrib['hostname'].strip() + self.provider = self.xml.attrib.get('provider', 'systemd').strip() handler = None if self.provider == 'netctl': import aif.network.netctl as handler @@ -24,3 +27,14 @@ class Network(object): elif e.tag == 'wireless': conn = self.provider.Wireless(e) self.connections.append(conn) + + def apply(self, chroot_base): + cfg = os.path.join(chroot_base, 'etc', 'hostname') + with open(cfg, 'w') as fh: + fh.write('{0}\n'.format(self.hostname)) + os.chown(cfg, 0, 0) + os.chmod(cfg, 0o0644) + # TODO: symlinks for systemd for provider + # TODO: writeConf for provider + + return() diff --git a/aif/network/netctl.py b/aif/network/netctl.py index e69de29..11cd01a 100644 --- a/aif/network/netctl.py +++ b/aif/network/netctl.py @@ -0,0 +1,277 @@ +import configparser +import io +import os +## +import aif.utils +import aif.network._common + + +class Connection(aif.network._common.BaseConnection): + def __init__(self, iface_xml): + super().__init__(iface_xml) + # TODO: disabling default route is not supported in-band. + # https://bugs.archlinux.org/task/64651 + # TODO: disabling autoroutes is not supported in-band. + # https://bugs.archlinux.org/task/64651 + # TODO: netctl profiles only support a single gateway. + # is there a way to manually add alternative gateways? + if not self.dhcp_client: + self.dhcp_client = 'dhcpcd' + self.provider_type = 'netctl' + self.packages = {'netctl', 'openresolv'} + self.services = {('/usr/lib/systemd/system/netctl@.service'): ('etc/systemd/system' + '/multi-user.target.wants' + '/netctl@{0}.service').format(self.id)} + # Only used if we need to override default dhcp/dhcp6 behaviour. I don't *think* we can customize SLAAC? + self.chroot_dir = os.path.join('etc', 'netctl', 'custom', self.dhcp_client) + self.chroot_cfg = os.path.join(self.chroot_dir, self.id) + self.desc = None + + def _initCfg(self): + if self.device == 'auto': + self.device = aif.network._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() + self._cfg.optionxform = str + # configparser *requires* sections. netctl doesn't use them. We strip it when we write. + self._cfg['BASE'] = {'Description': self.desc, + 'Interface': self.device, + 'Connection': self.connection_type} + # Addresses + if self.auto['addresses']['ipv4']: + self.packages.add(self.dhcp_client) + self._cfg['BASE']['IP'] = 'dhcp' + self._cfg['BASE']['DHCPClient'] = self.dhcp_client + else: + if self.addrs['ipv4']: + self._cfg['BASE']['IP'] = 'static' + else: + self._cfg['BASE']['IP'] = 'no' + if self.domain: + self._cfg['BASE']['DNSSearch'] = self.domain + if self.auto['addresses']['ipv6']: + if self.auto['addresses']['ipv6'] == 'slaac': + self._cfg['BASE']['IP6'] = 'stateless' + elif self.auto['addresses']['ipv6'] == 'dhcp6': + self._cfg['BASE']['IP6'] = 'dhcp' + self._cfg['BASE']['DHCP6Client'] = self.dhcp_client + self.packages.add(self.dhcp_client) + else: + if not self.addrs['ipv6']: + self._cfg['BASE']['IP6'] = 'no' + else: + self._cfg['BASE']['IP6'] = 'static' + for addrtype in ('ipv4', 'ipv6'): + keysuffix = ('6' if addrtype == 'ipv6' else '') + addrkey = 'Address{0}'.format(keysuffix) + gwkey = 'Gateway{0}'.format(keysuffix) + str_addrs = [] + if self.addrs[addrtype] and not self.auto['addresses'][addrtype]: + for ip, cidr, gw in self.addrs[addrtype]: + if not self.is_defroute: + self._cfg['BASE'][gwkey] = str(gw) + str_addrs.append("'{0}/{1}'".format(str(ip), str(cidr.prefixlen))) + self._cfg['BASE'][addrkey] = '({0})'.format(' '.join(str_addrs)) + elif self.addrs[addrtype]: + if 'IPCustom' not in self._cfg['BASE']: + # TODO: do this more cleanly somehow? Might conflict with other changes earlier/later. + # Weird hack because netctl doesn't natively support assigning add'l addrs to + # a dhcp/dhcp6/slaac iface. + self._cfg['BASE']['IPCustom'] = [] + for ip, cidr, gw in self.addrs[addrtype]: + self._cfg['BASE']['IPCustom'].append("'ip address add {0}/{1} dev {2}'".format(str(ip), + str(cidr.prefixlen), + self.device)) + # Resolvers may also require a change to /etc/resolvconf.conf? + for addrtype in ('ipv4', 'ipv6'): + if self.resolvers: + resolverkey = 'DNS' + str_resolvers = [] + for r in self.resolvers: + str_resolvers.append("'{0}'".format(str(r))) + self._cfg['BASE'][resolverkey] = '({0})'.format(' '.join(str_resolvers)) + # Routes + for addrtype in ('ipv4', 'ipv6'): + if self.routes[addrtype]: + keysuffix = ('6' if addrtype == 'ipv6' else '') + routekey = 'Routes{0}'.format(keysuffix) + str_routes = [] + for dest, net, gw in self.routes[addrtype]: + str_routes.append("'{0}/{1} via {2}'".format(str(dest), + str(net.prefixlen), + str(gw))) + self._cfg['BASE'][routekey] = '({0})'.format(' '.join(str_routes)) + # Weird hack because netctl doesn't natively support assigning add'l addrs to a dhcp/dhcp6/slaac iface. + if 'IPCustom' in self._cfg['BASE'].keys() and isinstance(self._cfg['BASE']['IPCustom'], list): + self._cfg['BASE']['IPCustom'] = '({0})'.format(' '.join(self._cfg['BASE']['IPCustom'])) + return() + + def writeConf(self, chroot_base): + systemd_base = os.path.join(chroot_base, 'etc', 'systemd', 'system') + systemd_file = os.path.join(systemd_base, 'netctl@{0}.service.d'.format(self.id), 'profile.conf') + netctl_file = os.path.join(chroot_base, 'etc', 'netctl', self.id) + for f in (systemd_file, netctl_file): + dpath = os.path.dirname(f) + os.makedirs(dpath, exist_ok = True) + os.chmod(dpath, 0o0755) + os.chown(dpath, 0, 0) + for root, dirs, files in os.walk(dpath): + for d in dirs: + fulld = os.path.join(root, d) + os.chmod(fulld, 0o0755) + os.chown(fulld, 0, 0) + systemd_cfg = configparser.ConfigParser() + systemd_cfg.optionxform = str + systemd_cfg['Unit'] = {'Description': self.desc, + 'BindsTo': 'sys-subsystem-net-devices-{0}.device'.format(self.device), + 'After': 'sys-subsystem-net-devices-{0}.device'.format(self.device)} + with open(systemd_file, 'w') as fh: + systemd_cfg.write(fh, space_around_delimiters = False) + # This is where it gets... weird. + # Gross hacky workarounds because netctl, while great for simple setups, sucks for complex/advanced ones. + no_auto = not all((self.auto['resolvers']['ipv4'], + self.auto['resolvers']['ipv6'], + self.auto['routes']['ipv4'], + self.auto['routes']['ipv6'])) + no_dhcp = not any((self.auto['addresses']['ipv4'], + self.auto['addresses']['ipv6'])) + if (no_auto and not no_dhcp) or (not self.is_defroute and not no_dhcp): + if self.dhcp_client == 'dhcpcd': + if not all((self.auto['resolvers']['ipv4'], + self.auto['routes']['ipv4'], + self.auto['addresses']['ipv4'])): + self._cfg['BASE']['DhcpcdOptions'] = "'--config {0}'".format(os.path.join('/', self.chroot_cfg)) + if not all((self.auto['resolvers']['ipv6'], + self.auto['routes']['ipv6'], + self.auto['addresses']['ipv6'])): + self._cfg['BASE']['DhcpcdOptions6'] = "'--config {0}'".format(os.path.join('/', self.chroot_cfg)) + elif self.dhcp_client == 'dhclient': + if not all((self.auto['resolvers']['ipv4'], + self.auto['routes']['ipv4'], + self.auto['addresses']['ipv4'])): + self._cfg['BASE']['DhcpcdOptions'] = "'-cf {0}'".format(os.path.join('/', self.chroot_cfg)) + if not all((self.auto['resolvers']['ipv6'], + self.auto['routes']['ipv6'], + self.auto['addresses']['ipv6'])): + self._cfg['BASE']['DhcpcdOptions6'] = "'-cf {0}'".format(os.path.join('/', self.chroot_cfg)) + custom_dir = os.path.join(chroot_base, self.chroot_dir) + custom_cfg = os.path.join(chroot_base, self.chroot_cfg) + os.makedirs(custom_dir, exist_ok = True) + for root, dirs, files in os.walk(custom_dir): + os.chown(root, 0, 0) + os.chmod(root, 0o0755) + for d in dirs: + dpath = os.path.join(root, d) + os.chown(dpath, 0, 0) + os.chmod(dpath, 0o0755) + for f in files: + fpath = os.path.join(root, f) + os.chown(fpath, 0, 0) + os.chmod(fpath, 0o0644) + # Modify DHCP options. WHAT a mess. + # The default requires are VERY sparse, and fine to remain unmangled for what we do. + opts = {} + for x in ('requests', 'requires'): + opts[x] = {} + for t in ('ipv4', 'ipv6'): + opts[x][t] = list(self.dhcp_defaults[self.dhcp_client][x][t]) + opt_map = { + 'dhclient': { + 'resolvers': { + 'ipv4': ('domain-name-servers', ), + 'ipv6': ('dhcp6.domain-name-servers', )}, + 'routes': { + 'ipv4': ('rfc3442-classless-static-routes', 'static-routes'), + 'ipv6': tuple()}, # ??? + # There is no way, as far as I can tell, to tell dhclient to NOT request an address. + 'addresses': { + 'ipv4': tuple(), + 'ipv6': tuple()}}, + 'dhcpcd': { + 'resolvers': { + 'ipv4': ('domain_name_servers', ), + 'ipv6': ('dhcp6_domain_name_servers', )}, + 'routes': { + 'ipv4': ('classless_static_routes', 'static_routes'), + 'ipv6': tuple()}, # ??? + # I don't think dhcpcd lets us refuse an address. + 'addresses': { + 'ipv4': tuple(), + 'ipv6': tuple()}}} + # This ONLY works for DHCPv6 on the IPv6 side. Not SLAAC. Netctl doesn't use a dhcp client for + # SLAAC. + # x = routers, addresses, resolvers + # t = ipv4/ipv6 dicts + # i = ipv4/ipv6 key + # v = boolean of auto + # o = each option for given auto type and IP type + for x, t in self.auto.items(): + for i, v in t.items(): + if not v: + for o in opt_map[self.dhcp_client][x][i]: + for n in ('requests', 'requires'): + if o in opts[n][i]: + opts[n][i].remove(o) + # We don't want the default route if we're not the default route iface. + if not self.is_defroute: + # IPv6 uses RA for the default route... We'll probably need to do that via an ExecUpPost? + # TODO. + for i in ('requests', 'requires'): + if 'routers' in opts[i]['ipv4']: + opts[i]['ipv4'].remove('routers') + if self.dhcp_client == 'dhclient': + conf = ['lease {', + ' interface "{0}";'.format(self.device), + '}'] + for i in ('request', 'require'): + k = '{0}s'.format(i) + optlist = [] + for t in ('ipv4', 'ipv6'): + optlist.extend(opts[k][t]) + if optlist: + conf.insert(-1, ' {0} {1};'.format(k, ', '.join(optlist))) + elif self.dhcp_client == 'dhcpcd': + conf = [] + conf.extend(list(self.dhcp_defaults['dhcpcd']['default_opts'])) + for i in ('requests', 'requires'): + if i == 'requests': + k = 'option' + else: + k = 'require' + optlist = [] + optlist.extend(opts[i]['ipv4']) + optlist.extend(opts[i]['ipv6']) + # TODO: does require support comma-separated list like option does? + conf.append('{0} {1};'.format(k, ','.join(optlist))) + with open(custom_cfg, 'w') as fh: + fh.write('\n'.join(conf)) + fh.write('\n') + os.chmod(custom_cfg, 0o0644) + os.chown(custom_cfg, 0, 0) + # And we have to strip out the section from the ini. + cfgbuf = io.StringIO() + self._cfg.write(cfgbuf, space_around_delimiters = False) + cfgbuf.seek(0, 0) + with open(netctl_file, 'w') as fh: + for line in cfgbuf.readlines(): + if line.startswith('[BASE]') or line.strip() == '': + continue + fh.write(line) + os.chmod(netctl_file, 0o0600) + os.chown(netctl_file, 0, 0) + return() + + +class Ethernet(Connection): + def __init__(self, iface_xml): + super().__init__(iface_xml) + self.connection_type = 'ethernet' + self._initCfg() + + +class Wireless(Connection): + def __init__(self, iface_xml): + super().__init__(iface_xml) + self.connection_type = 'wireless' + self._initCfg() diff --git a/aif/network/networkd.py b/aif/network/networkd.py index dca0967..0c1bb62 100644 --- a/aif/network/networkd.py +++ b/aif/network/networkd.py @@ -1,4 +1,7 @@ import ipaddress import socket - - +## +# We have to use Jinja2 because while there are ways to *parse* an INI with duplicate keys +# (https://stackoverflow.com/a/38286559/733214), there's no way to *write* an INI with them using configparser. +# So we use Jinja2 logic. +import jinja2 diff --git a/aif/network/networkd_fallback.py b/aif/network/networkd_fallback.py deleted file mode 100644 index e69de29..0000000 diff --git a/aif/network/networkmanager.py b/aif/network/networkmanager.py index a477d32..e1facd4 100644 --- a/aif/network/networkmanager.py +++ b/aif/network/networkmanager.py @@ -1,59 +1,33 @@ import configparser import datetime -import ipaddress import os import uuid ## import aif.utils - -# TODO: auto dev assignment +import aif.network._common -class Connection(object): +class Connection(aif.network._common.BaseConnection): def __init__(self, iface_xml): - self.xml = iface_xml - self.id = self.xml.attrib['id'] - self.device = self.xml.attrib['device'] - self.is_defroute = aif.utils.xmlBool(self.xml.attrib.get('defroute', 'false')) - self.domain = self.xml.attrib.get('searchDomain', None) - self._cfg = None - self.connection_type = None + super().__init__(iface_xml) self.provider_type = 'NetworkManager' - self.addrs = {'ipv4': set(), - 'ipv6': set()} - self.resolvers = [] + self.packages = set('networkmanager') + self.services = { + ('/usr/lib/systemd/system/NetworkManager.service'): ('etc/systemd/system/' + 'multi-user.target.wants/' + 'NetworkManager.service'), + ('/usr/lib/systemd/system/NetworkManager-dispatcher.service'): ('etc/systemd/system/' + 'dbus-org.freedesktop.' + 'nm-dispatcher.service'), + ('/usr/lib/systemd/system/NetworkManager-wait-online.service'): ('etc/systemd/' + 'system/' + 'network-online.target.wants/' + 'NetworkManager-wait-online.service')} self.uuid = uuid.uuid4() - self._initAddrs() - self._initResolvers() - - def _initAddrs(self): - # These tuples follow either: - # ('dhcp'/'dhcp6'/'slaac', None, None) for auto configuration - # (ipaddress.IPv4/6Address(IP), CIDR, ipaddress.IPv4/6Address(GW)) for static configuration - for addrtype in ('ipv4', 'ipv6'): - for a in self.xml.findall('addresses/{0}/address'.format(addrtype)): - if a.text in ('dhcp', 'dhcp6', 'slaac'): - addr = a.text - net = None - gw = None - else: - components = a.text.split('/') - if len(components) > 2: - raise ValueError('Invalid IP/CIDR format: {0}'.format(a.text)) - if len(components) == 1: - addr = components[0] - if addrtype == 'ipv4': - components.append('24') - elif addrtype == 'ipv6': - components.append('64') - addr = ipaddress.ip_address(components[0]) - net = ipaddress.ip_network('/'.join(components), strict = False) - gw = ipaddress.ip_address(a.attrib.get('gateway')) - self.addrs[addrtype].add((addr, net, gw)) - self.addrs[addrtype] = list(self.addrs[addrtype]) - return() def _initCfg(self): + if self.device == 'auto': + self.device = aif.network._common.getDefIface(self.connection_type) self._cfg = configparser.ConfigParser() self._cfg.optionxform = str self._cfg['connection'] = {'id': self.id, @@ -63,61 +37,60 @@ class Connection(object): 'permissions': '', 'timestamp': datetime.datetime.utcnow().timestamp()} # We *theoretically* could do this in _initAddrs() but we do it separately so we can trim out duplicates. + # TODO: rework this? we technically don't need to split in ipv4/ipv6 since ipaddress does that for us. for addrtype, addrs in self.addrs.items(): self._cfg[addrtype] = {} cidr_gws = {} + # Routing + if not self.is_defroute: + self._cfg[addrtype]['never-default'] = 'true' + if not self.auto['routes'][addrtype]: + self._cfg[addrtype]['ignore-auto-routes'] = 'true' + # DNS self._cfg[addrtype]['dns-search'] = (self.domain if self.domain else '') + if not self.auto['resolvers'][addrtype]: + self._cfg[addrtype]['ignore-auto-dns'] = 'true' + # Address handling if addrtype == 'ipv6': self._cfg[addrtype]['addr-gen-mode'] = 'stable-privacy' - if not addrs: + if not addrs and not self.auto['addresses'][addrtype]: self._cfg[addrtype]['method'] = 'ignore' + elif self.auto['addresses'][addrtype]: + if addrtype == 'ipv4': + self._cfg[addrtype]['method'] = 'auto' + else: + self._cfg[addrtype]['method'] = ('auto' if self.auto['addresses'][addrtype] == 'slaac' + else 'dhcp6') else: self._cfg[addrtype]['method'] = 'manual' - for idx, (ip, cidr, gw) in enumerate(addrs): - if cidr not in cidr_gws.keys(): - cidr_gws[cidr] = gw - new_cidr = True - else: - new_cidr = False - if addrtype == 'ipv4': - if ip == 'dhcp': - self._cfg[addrtype]['method'] = 'auto' - continue - elif addrtype == 'ipv6': - if ip == 'dhcp6': - self._cfg[addrtype]['method'] = 'dhcp' - continue - elif ip == 'slaac': - self._cfg[addrtype]['method'] = 'auto' - continue - addrnum = idx + 1 - addr_str = '{0}/{1}'.format(str(ip), str(cidr.prefixlen)) - if new_cidr: - addr_str = '{0},{1}'.format(addr_str, str(gw)) - self._cfg[addrtype]['address{0}'.format(addrnum)] = addr_str - for r in self.resolvers: - if addrtype == 'ipv{0}'.format(r.version): + for idx, (ip, cidr, gw) in enumerate(addrs): + if cidr not in cidr_gws.keys(): + cidr_gws[cidr] = gw + new_cidr = True + else: + new_cidr = False + addrnum = idx + 1 + addr_str = '{0}/{1}'.format(str(ip), str(cidr.prefixlen)) + if new_cidr: + addr_str = '{0},{1}'.format(addr_str, str(gw)) + self._cfg[addrtype]['address{0}'.format(addrnum)] = addr_str + # Resolvers + for resolver in self.resolvers: + if addrtype == 'ipv{0}'.format(resolver.version): if 'dns' not in self._cfg[addrtype]: self._cfg[addrtype]['dns'] = [] - self._cfg[addrtype]['dns'].append(str(r)) + self._cfg[addrtype]['dns'].append(str(resolver)) if 'dns' in self._cfg[addrtype].keys(): self._cfg[addrtype]['dns'] = '{0};'.format(';'.join(self._cfg[addrtype]['dns'])) + # Routes + for idx, (dest, net, gw) in self.routes[addrtype]: + routenum = idx + 1 + self._cfg[addrtype]['route{0}'.format(routenum)] = '{0}/{1},{2}'.format(str(dest), + str(net.prefixlen), + str(gw)) self._initConnCfg() return() - def _initConnCfg(self): - # A dummy method; this is overridden by the subclasses. - # It's honestly here to make my IDE stop complaining. :) - pass - return() - - def _initResolvers(self): - for r in self.xml.findall('resolvers/resolver'): - resolver = ipaddress.ip_address(r.text) - if resolver not in self.resolvers: - self.resolvers.append(resolver) - return() - def writeConf(self, chroot_base): cfgroot = os.path.join(chroot_base, 'etc', 'NetworkManager') cfgdir = os.path.join(cfgroot, 'system-connections') @@ -146,7 +119,8 @@ class Ethernet(Connection): self._initCfg() def _initConnCfg(self): - pass + self._cfg[self.connection_type] = {'mac-address-blacklist': ''} + return() class Wireless(Connection): @@ -156,4 +130,27 @@ class Wireless(Connection): self._initCfg() def _initConnCfg(self): - pass + self._cfg['wifi'] = {'mac-address-blacklist': '', + 'mode': 'infrastructure', + 'ssid': self.xml.attrib['essid']} + try: + bssid = self.xml.attrib.get('bssid').strip() + except AttributeError: + bssid = None + if bssid: + bssid = aif.network._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) + # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): + if crypto['type'] in ('wpa', 'wpa2'): + # TODO: WPA2 enterprise + self._cfg['wifi-security']['key-mgmt'] = 'wpa-psk' + # if crypto['type'] in ('wep', 'wpa', 'wpa2', 'wpa3'): + if crypto['type'] in ('wpa', 'wpa2'): + self._cfg['wifi-security']['psk'] = crypto['auth']['psk'] + return() diff --git a/aif/utils.py b/aif/utils.py index 48ef2da..9aa042d 100644 --- a/aif/utils.py +++ b/aif/utils.py @@ -1,6 +1,8 @@ import math import os +import pathlib import re +import shlex import subprocess ## import psutil @@ -55,6 +57,22 @@ def isPowerofTwo(n): return(isPowerOf2) +def kernelCmdline(chroot_base = '/'): + cmds = {} + chroot_base = pathlib.PosixPath(chroot_base) + cmdline = chroot_base.joinpath('proc', 'cmdline') + if not os.path.isfile(cmdline): + return(cmds) + with open(cmdline, 'r') as fh: + raw_cmds = fh.read().strip() + for c in shlex.split(raw_cmds): + l = c.split('=', 1) + if len(l) < 2: + l.append(None) + cmds[l[0]] = l[1] + return(cmds) + + def kernelFilesystems(): # I wish there was a better way of doing this. # https://unix.stackexchange.com/a/98680 diff --git a/docs/MANUAL.adoc b/docs/MANUAL.adoc index 66b82ea..32f1e31 100644 --- a/docs/MANUAL.adoc +++ b/docs/MANUAL.adoc @@ -571,6 +571,9 @@ Using `start`/`stop` attributes makes sense for disk partitions because they ope LVM (LVs, in particular), however, aren't consecutive. There *is* no concept of a "start" and "stop" for an LV; LVM uses chunks called "(physical) extents" rather than sectors, and VGs don't have geometry since they're essentially a pool of blocks. This is also why the modifiers like `-` and `+` aren't allowed for LV sizes - they're position-based. +=== "How do I specify packages from the AUR?" +You'd have to https://wiki.archlinux.org/index.php/Makepkg[build the package(s)^], https://wiki.archlinux.org/index.php/Pacman/Tips_and_tricks#Custom_local_repository[set up a repository^], serve it via e.g. https://www.nginx.com/[nginx^], and add it as a repo (`/aif/pacman/repos/repo`) first. Then you can specify the package as normal as a `/aif/pacman/software/package` item. + === "Why aren't the network settings in being applied during install?" Simply put, a logical race condition. In order for probably 90+% of AIF-NG deploys to bootstrap, they fetch their XML configuration via a network URI (rather than a file URI). This means it needs a network connection that pre-exists in the *install environment* (LiveCD, LiveUSB, PXE/iPXE, etc.) before it even knows what network configuration you want the *persistent environment* to have. @@ -580,6 +583,23 @@ If you desire the configuration to be applied *during* the install, you can do i If you wish to SSH into the install environment to check the status/progress of the install, it is recommended that you set up a static lease (if using DHCP) or use SLAAC (if using IPv6) beforehand and configure your install environment beforehand. Remember, AIF-NG only *installs* Arch Linux; it tries very hard to *not* interact with the install environment. +=== "Why isn't enabling/disabling automatic DNS resolvers working?" +This is going to be highly unpredictable based on the networking provider you choose. This is a limitation of underlying network provider intercompatibility, resolver libraries, and technology architecture. This may be changed in the future, but because of how DNS servers are handled via DHCP/RDNSS and glibc (and the fact that IPv4 resolver addresses can serve IPv6 -- e.g. AAAA -- records and vice versa) and inherent limitations in some network providers like netctl, I wouldn't hold your breath. + +=== "I'm using netctl as my network provider, and-" +I'ma let you finish, but netctl is a *really* simple network provider. I mean REALLY simple. As such, a lot of things (like mixing auto DNS and non-auto addressing) don't work at all feasibly, and probably might not ever. It's great for simple and flat configurations (i.e. all static everything, all automatic everything, etc.) and I even use it on my own machines where I can, but it just simply doesn't make allowances for more complex setups. (This is why init scripts were replaced by systemd for init, remember? Script-and-shell-based utilities, such as netctl -- seriously, the entire thing's written in Bash -- just can't handle more complex jobs reliably.) + +If you need more advanced functionality but don't want a lot of cruft or bloat, I recommend `systemd` as your network provider. It requires no extra packages (other than wpa_supplicant, if you're using wireless) because it's part of the systemd package (which is part of the most basic install of Arch) and handles more advanced configurations a lot more reliably. + +=== "How do I specify WEP for a wireless network?" +You can't. WEP's pretty broken. I understand some legacy networks may still use it, but I'm incredibly uncomfortable supporting it. + +If absolutely necessary, you can manually configure it yourself via a `/aif/scripts/post/script` script. + +=== "How do I connect to a WPA2 Enterprise network?" +You can't, currently; support is only stubbed out for now. If absolutely necessary, you can manually configure it yourself via a `/aif/scripts/post/script` script. + +This hopefully will be changed in the future, however, as I'm interested in adding support. For now, WPA/WPA2 PSK only are considered supported. == Bug Reports/Feature Requests NOTE: It is possible to submit a bug or feature request without registering in my bugtracker. One of my pet peeves is needing to create an account/register on a bugtracker simply to report a bug! The following links only require an email address to file a bug (which is necessary in case I need any further clarification from you or to keep you updated on the status of the bug/feature request -- so please be sure to use a valid email address). diff --git a/examples/aif.xml b/examples/aif.xml index 5306aa0..8bb9b47 100644 --- a/examples/aif.xml +++ b/examples/aif.xml @@ -122,16 +122,23 @@ - -
dhcp
+
192.168.1.5/24
- -
slaac
+
fde4:16b9:654b:bbfa::15/64
- + + + 10.1.1.0/24 + 172.16.1.20/32 + + + + + + 64.6.64.6 4.2.2.1 8.8.8.8 @@ -140,10 +147,11 @@ - -
dhcp
-
+
+ + + wpa2 personal