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()