From edc78ea18ec358ce307722297215abf4ac3de714 Mon Sep 17 00:00:00 2001 From: brent s Date: Sun, 1 Dec 2019 05:07:20 -0500 Subject: [PATCH] checking in before i do some major restructuring of wifi stuff in the xml/xsd --- aif.xsd | 10 ++- aif/network/_common.py | 27 +++++- aif/network/netctl.py | 4 +- aif/network/networkd.conf.j2 | 19 +++++ aif/network/networkd.py | 153 +++++++++++++++++++++++++++++++++- aif/network/networkmanager.py | 2 +- aif/utils.py | 14 ++++ docs/MANUAL.adoc | 21 +++-- examples/aif.xml | 4 +- extras/genPSK.py | 82 ++++++++++++++++++ 10 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 aif/network/networkd.conf.j2 create mode 100755 extras/genPSK.py diff --git a/aif.xsd b/aif.xsd index f8257b1..a5dc867 100644 --- a/aif.xsd +++ b/aif.xsd @@ -347,8 +347,6 @@ - @@ -377,8 +375,6 @@ - @@ -443,6 +439,12 @@ + + + + diff --git a/aif/network/_common.py b/aif/network/_common.py index 9d7312c..3887d06 100644 --- a/aif/network/_common.py +++ b/aif/network/_common.py @@ -1,8 +1,10 @@ +import binascii import ipaddress import os import pathlib import re ## +from passlib.crypto.digest import pbkdf2_hmac from pyroute2 import IPDB ## import aif.utils @@ -47,7 +49,22 @@ def convertIpTuples(addr_xmlobj): return((addr, net, gw)) -def convertWifiCrypto(crypto_xmlobj): +def convertPSK(ssid, passphrase): + try: + passphrase = passphrase.encode('utf-8').decode('ascii').strip('\r').strip('\n') + except UnicodeDecodeError: + raise ValueError('passphrase must be an ASCII string') + if len(ssid) > 32: + raise ValueError('ssid must be <= 32 characters') + if not 7 < len(passphrase) < 64: + raise ValueError('passphrase must be >= 8 and <= 32 characters') + raw_psk = pbkdf2_hmac('sha1', str(passphrase), str(ssid), 4096, 32) + hex_psk = binascii.hexlify(raw_psk) + str_psk = hex_psk.decode('utf-8') + return(str_psk) + + +def convertWifiCrypto(crypto_xmlobj, ssid): crypto = {'type': crypto_xmlobj.find('type').text.strip()} # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): if crypto['type'] in ('wpa', 'wpa2'): @@ -61,7 +78,8 @@ def convertWifiCrypto(crypto_xmlobj): creds = crypto_xmlobj.find('creds') crypto['auth'] = {'type': creds.attrib.get('type', 'psk').strip()} if crypto['auth']['type'] == 'psk': - crypto['auth']['psk'] = creds.text + crypto['auth']['passphrase'] = creds.text.strip('\r').strip('\n') + crypto['auth']['psk'] = convertPSK(ssid, creds.text) # TODO: enterprise support return(crypto) @@ -211,3 +229,8 @@ class BaseConnection(object): if addrset not in self.routes[addrtype]: self.routes[addrtype].append(addrset) return() + + def _writeConnCfg(self, chroot_base = None): + # Dummy method. + pass + return() diff --git a/aif/network/netctl.py b/aif/network/netctl.py index 9fd8d84..5df977d 100644 --- a/aif/network/netctl.py +++ b/aif/network/netctl.py @@ -274,6 +274,7 @@ class Wireless(Connection): def __init__(self, iface_xml): super().__init__(iface_xml) self.connection_type = 'wireless' + self.packages.add('wpa_supplicant') self._initCfg() self._initConnCfg() @@ -291,8 +292,7 @@ class Wireless(Connection): self._cfg['BASE']['AP'] = bssid crypto = self.xml.find('encryption') if crypto: - self.packages.add('wpa_supplicant') - crypto = aif.network._common.convertWifiCrypto(crypto) + crypto = aif.network._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.conf.j2 b/aif/network/networkd.conf.j2 new file mode 100644 index 0000000..60e783a --- /dev/null +++ b/aif/network/networkd.conf.j2 @@ -0,0 +1,19 @@ +# Generated by AIF-NG. +{%- for section_name, section_items in cfg.items() %} + {%- if section_items|isList %} + {#- We *only* use lists-of-dicts because they should always render to their own sections. + INI doesn't support nesting, thankfully. #} + {%- for i in section_items %} +[{{ section_name }}] + {%- for k, v in i.items() %} +{{ k }}={{ v }} + {%- endfor %} + {% endfor %} + {%- else %} + {#- It's a single-level dict. #} +[{{ section_name }}] + {%- for k, v in section_items.items() %} +{{ k }}={{ v }} + {%- endfor %} + {%- endif %} +{% endfor %} diff --git a/aif/network/networkd.py b/aif/network/networkd.py index 2b5dd8a..85186a1 100644 --- a/aif/network/networkd.py +++ b/aif/network/networkd.py @@ -1,8 +1,157 @@ -import ipaddress -import socket +import os ## # 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 +## +import aif.utils +import aif.network._common + +class Connection(aif.network._common.BaseConnection): + def __init__(self, iface_xml): + super().__init__(iface_xml) + self.provider_type = 'systemd-networkd' + self.packages = set() + self.services = { + ('/usr/lib/systemd/system/systemd-networkd.service'): ('etc/systemd/system/' + 'multi-user.target.wants/' + 'systemd-networkd.service'), + ('/usr/lib/systemd/system/systemd-networkd.service'): ('etc/systemd/system/' + 'dbus-org.freedesktop.network1.service'), + ('/usr/lib/systemd/system/systemd-networkd.socket'): ('etc/systemd/system/' + 'sockets.target.wants/systemd-networkd.socket'), + ('/usr/lib/systemd/system/systemd-networkd.socket'): ('etc/systemd/system/' + 'network-online.target.wants/' + 'systemd-networkd-wait-online.service'), + # We include these *even if* self.auto['resolvers'][*] are false. + ('/usr/lib/systemd/system/systemd-resolved.service'): ('etc/systemd/system/' + 'dbus-org.freedesktop.resolve1.service'), + ('/usr/lib/systemd/system/systemd-resolved.service'): ('etc/systemd/' + 'system/multi-user.target.wants/' + 'systemd-resolved.service')} + self._wpasupp = {} + self._initJ2() + + def _initCfg(self): + if self.device == 'auto': + self.device = aif.network._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, + self.device), + 'DefaultRouteOnDevice': ('true' if self.is_defroute else 'false'), + # This (may) get modified by logic below. + 'IPv6AcceptRA': 'false', + 'LinkLocalAddressing': 'no'}} + if self.domain: + self._cfg['Network']['Domains'] = self.domain + if self.resolvers: + self._cfg['Network']['DNS'] = [str(ip) for ip in self.resolvers] + if all((self.auto['addresses']['ipv4'], self.auto['addresses']['ipv6'])): + self._cfg['Network']['IPv6AcceptRA'] = 'true' + self._cfg['Network']['LinkLocalAddressing'] = 'ipv6' + self._cfg['Network']['DHCP'] = 'yes' + elif self.auto['addresses']['ipv4'] and not self.auto['addresses']['ipv6']: + self._cfg['Network']['DHCP'] = 'ipv4' + elif (not self.auto['addresses']['ipv4']) and self.auto['addresses']['ipv6']: + self._cfg['Network']['IPv6AcceptRA'] = 'true' + self._cfg['Network']['LinkLocalAddressing'] = 'ipv6' + self._cfg['Network']['DHCP'] = 'ipv6' + else: + self._cfg['Network']['DHCP'] = 'no' + if any((self.auto['addresses']['ipv4'], self.auto['routes']['ipv4'], self.auto['resolvers']['ipv4'])): + t = 'ipv4' + self._cfg['DHCPv4'] = {'UseDNS': ('true' if self.auto['resolvers'][t] else 'false'), + 'UseRoutes': ('true' if self.auto['routes'][t] else 'false')} + if any((self.auto['addresses']['ipv6'], self.auto['routes']['ipv6'], self.auto['resolvers']['ipv6'])): + t = 'ipv6' + self._cfg['Network']['IPv6AcceptRA'] = 'true' + self._cfg['DHCPv6'] = {'UseDNS': ('true' if self.auto['resolvers'][t] else 'false')} + for t in ('ipv4', 'ipv6'): + if self.addrs[t]: + if t == 'ipv6': + self._cfg['Network']['LinkLocalAddressing'] = 'ipv6' + if 'Address' not in self._cfg.keys(): + self._cfg['Address'] = [] + for addr, net, gw in self.addrs[t]: + a = {'Address': '{0}/{1}'.format(str(addr), str(net.prefixlen))} + self._cfg['Address'].append(a) + if self.routes[t]: + if 'Route' not in self._cfg.keys(): + self._cfg['Route'] = [] + for route, net, gw in self.routes[t]: + r = {'Gateway': str(gw), + 'Destination': '{0}/{1}'.format(str(route), str(net.prefixlen))} + self._cfg['Route'].append(r) + if self._cfg['Network']['IPv6AcceptRA'] == 'true': + self._cfg['Network']['LinkLocalAddressing'] = 'ipv6' + if 'IPv6AcceptRA' not in self._cfg.keys(): + self._cfg['IPv6AcceptRA'] = {'UseDNS': ('true' if self.auto['resolvers']['ipv6'] else 'false')} + self._initConnCfg() + return() + + def _initJ2(self): + self.j2_env = jinja2.Environment(loader = jinja2.FileSystemLoader(searchpath = './')) + self.j2_env.filters.update(aif.utils.j2_filters) + self.j2_tpl = self.j2_env.get_template('networkd.conf.j2') + return() + + def writeConf(self, chroot_base): + cfgroot = os.path.join(chroot_base, 'etc', 'systemd', 'network') + cfgfile = os.path.join(cfgroot, self.id) + os.makedirs(cfgroot, exist_ok = True) + os.chown(cfgroot, 0, 0) + os.chmod(cfgroot, 0o0755) + with open(cfgfile, 'w') as fh: + fh.write(self.j2_tpl.render(cfg = self._cfg)) + os.chmod(cfgfile, 0o0644) + os.chown(cfgfile, 0, 0) + self._writeConnCfg(chroot_base) + 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.packages.add('wpa_supplicant') + self.services['src'] = 'dest' + self._initCfg() + + def _initConnCfg(self): + self._wpasupp['ssid'] = self.xml.attrib['essid'] + hidden = aif.utils.xmlBool(self.xml.attrib.get('hidden', 'false')) + if hidden: + self._wpasupp['scan_ssid'] = 1 + try: + bssid = self.xml.attrib.get('bssid').strip() + except AttributeError: + bssid = None + if bssid: + bssid = aif.network._common.canonizeEUI(bssid) + self._cfg['BASE']['AP'] = bssid + crypto = self.xml.find('encryption') + if crypto: + crypto = aif.network._common.convertWifiCrypto(crypto, self._cfg['BASE']['ESSID']) + # if crypto['type'] in ('wpa', 'wpa2', 'wpa3'): + if crypto['type'] in ('wpa', 'wpa2'): + # TODO: WPA2 enterprise + self._cfg['BASE']['Security'] = 'wpa' + # if crypto['type'] in ('wep', 'wpa', 'wpa2', 'wpa3'): + if crypto['type'] in ('wpa', 'wpa2'): + self._cfg['BASE']['Key'] = crypto['auth']['psk'] + return() + + def _writeConnCfg(self, chroot_base): + cfgroot = os.path.join(chroot_base, 'etc', 'wpa_supplicant') + cfgbase = os.path.join(cfgroot, 'wpa_supplicant.conf') + cfgfile = os.path.join(cfgroot, self.id) diff --git a/aif/network/networkmanager.py b/aif/network/networkmanager.py index e1facd4..1eaf2bd 100644 --- a/aif/network/networkmanager.py +++ b/aif/network/networkmanager.py @@ -145,7 +145,7 @@ class Wireless(Connection): if crypto: self.packages.add('wpa_supplicant') self._cfg['wifi-security'] = {} - crypto = aif.network._common.convertWifiCrypto(crypto) + crypto = aif.network._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/utils.py b/aif/utils.py index 9aa042d..6f08fb3 100644 --- a/aif/utils.py +++ b/aif/utils.py @@ -57,6 +57,20 @@ def isPowerofTwo(n): return(isPowerOf2) +# custom Jinja2 filters +def j2_isDict(value): + return(isinstance(value, dict)) + + +def j2_isList(value): + return(isinstance(value, list)) + + +j2_filters = {'isDict': j2_isDict, + 'isList': j2_isList} +# end custom Jinja2 filters + + def kernelCmdline(chroot_base = '/'): cmds = {} chroot_base = pathlib.PosixPath(chroot_base) diff --git a/docs/MANUAL.adoc b/docs/MANUAL.adoc index 9f27b88..27edfd8 100644 --- a/docs/MANUAL.adoc +++ b/docs/MANUAL.adoc @@ -574,32 +574,35 @@ LVM (LVs, in particular), however, aren't consecutive. There *is* no concept of === "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?" +=== "Why can't the network settings in be 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. -Granted, this is a moot point if you're using a *`file://`* URI for the XML configuration, but this is not a very flexible means regardless. If demand increases for this, future releases may include this functionality. +Granted, this is a moot point if you're using a *`file://`* URI for the XML configuration, but this is not a very flexible means regardless. The installation host itself is outside the scope of AIF-NG. -If you desire the configuration to be applied *during* the install, you can do it yourself in an `/aif/scripts/pre/script` or `/aif/scripts/pkg/script` script. The fetched XML file can be found at `/var/tmp/AIF.xml` in the install environment. (Alternatively, configure the network yourself procedurally using one of those scripts). +If you desire the configuration to be applied *during* the install, you can do it yourself in an `/aif/scripts/pre/script` or `/aif/scripts/pkg/script` script. The fetched XML file can be found at `/var/tmp/AIF.xml` in the install environment. 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. +=== "Why isn't enabling/disabling automatic DNS resolvers/routes/addresses 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, there being no way to tell DHCP/DHCP6/SLAAC clients to *only* fetch information about a network and *not* assign a lease, 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.) +I'ma let you finish, but netctl is a *really* simple network provider. I mean REALLY simple. As such, a lot of things 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 `networkd` 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. +If absolutely necessary, you can manually configure it yourself via a `/aif/scripts/post/script` script (or just configure it once you boot the newly-installed system). -=== "How do I connect to a WPA2 Enterprise network?" +==== "Then why do you allow connecting to open wireless networks in the config?" +Because captive portals are a thing. *Authing* to them, however; that's out of my scope. + +=== "How do I configure connecting 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. +This hopefully will be changed in the future, however, as I'm interested in adding support. For now, open and 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 8bb9b47..cf5f5dd 100644 --- a/examples/aif.xml +++ b/examples/aif.xml @@ -130,11 +130,11 @@ - + 10.1.1.0/24 172.16.1.20/32 - + diff --git a/extras/genPSK.py b/extras/genPSK.py new file mode 100755 index 0000000..32a9f25 --- /dev/null +++ b/extras/genPSK.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +import argparse +import binascii +import getpass +import sys +## +# from passlib.utils import pbkdf2 # deprecated +from passlib.crypto.digest import pbkdf2_hmac + + +def pskGen(ssid, passphrase): + # raw_psk = pbkdf2.pbkdf2(str(passphrase), str(ssid), 4096, 32) # deprecated + raw_psk = pbkdf2_hmac('sha1', str(passphrase), str(ssid), 4096, 32) + hex_psk = binascii.hexlify(raw_psk) + str_psk = hex_psk.decode('utf-8') + return(str_psk) + + +def parseArgs(): + def essidchk(essid): + essid = str(essid) + if len(essid) > 32: + raise argparse.ArgumentTypeError('The maximum length of an ESSID is 32 characters') + return(essid) + + def passphrasechk(passphrase): + if passphrase: + is_piped = False + passphrase = str(passphrase) + if passphrase == '-': + if sys.stdin.isatty(): + raise argparse.ArgumentTypeError(('[STDIN] You specified a passphrase to be entered but did not ' + 'provide one via a pipe.')) + else: + is_piped = True + try: + # WPA-PSK only accepts ASCII for passphrase. + raw_pass = sys.stdin.read().encode('utf-8').decode('ascii').strip('\r').strip('\n') + except UnicodeDecodeError: + raise argparse.ArgumentTypeError('[STDIN] WPA-PSK passphrases must be an ASCII string') + if not 7 < len(passphrase) < 64: + raise argparse.ArgumentTypeError(('{0}WPA-PSK passphrases must be no shorter than 8 characters' + ' and no longer than 63 characters. ' + 'Please ensure you have provided the ' + 'correct passphrase.').format(('[STDIN] ' if is_piped else ''))) + return(passphrase) + + args = argparse.ArgumentParser(description = 'Generate a PSK from a passphrase') + args.add_argument('-p', '--passphrase', + dest = 'passphrase', + default = None, + type = passphrasechk, + help = ('If specified, use this passphrase (otherwise securely interactively prompt for it). ' + 'If "-" (without quotes), read from stdin (via a pipe). ' + 'WARNING: THIS OPTION IS INSECURE AND MAY EXPOSE THE PASSPHRASE GIVEN ' + 'TO OTHER PROCESSES ON THIS SYSTEM')) + args.add_argument('ssid', + metavar = 'ESSID', + type = essidchk, + help = ('The ESSID (network name) to use for this passphrase. ' + '(This is required because WPA-PSK uses it to salt the key derivation)')) + return(args) + + +def main(): + args = parseArgs().parse_args() + if not args.passphrase: + args.passphrase = getpass.getpass(('Please enter the passphrase for ' + 'network "{0}" (will NOT echo back): ').format(args.ssid)) + args.passphrase = args.passphrase.encode('utf-8').decode('ascii').strip('\r').strip('\n') + if not 7 < len(args.passphrase) < 64: + raise ValueError(('WPA-PSK passphrases must be no shorter than 8 characters' + ' and no longer than 63 characters. ' + 'Please ensure you have provided the correct passphrase.')) + psk = pskGen(args.ssid, args.passphrase) + print('PSK for network "{0}": {1}'.format(args.ssid, psk)) + return() + + +if __name__ == '__main__': + main()