diff --git a/firmware/.gitignore b/firmware/.gitignore index f3b12c3..dd9745b 100644 --- a/firmware/.gitignore +++ b/firmware/.gitignore @@ -1,2 +1,2 @@ /result -/*.img +/*.img* diff --git a/firmware/app.nix b/firmware/app.nix new file mode 100644 index 0000000..99bf786 --- /dev/null +++ b/firmware/app.nix @@ -0,0 +1,31 @@ +{ lib, pkgs, config, ... }: +let + configurer = pkgs.substituteAll { + src = ./configurer.py; + isExecutable = true; + + python = pkgs.python3.withPackages (ps: with ps; [ pyyaml ]); + wireguardTools = pkgs.wireguard-tools; + systemd = config.systemd.package; + }; +in +{ + config = { + systemd = { + services = { + qclk-configurer = { + description = "qclk dynamic configurer"; + after = [ "network.target" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${configurer}"; + }; + + wantedBy = [ "multi-user.target" ]; + }; + }; + }; + }; +} diff --git a/firmware/base.nix b/firmware/base.nix index 16c164f..acaa3b6 100644 --- a/firmware/base.nix +++ b/firmware/base.nix @@ -11,7 +11,7 @@ in }; name = "qclk"; }; - documentation.nixos.enable = false; + documentation.enable = false; time.timeZone = "Europe/Dublin"; i18n.defaultLocale = "en_IE.UTF-8"; diff --git a/firmware/configurer.py b/firmware/configurer.py new file mode 100755 index 0000000..9a486e6 --- /dev/null +++ b/firmware/configurer.py @@ -0,0 +1,102 @@ +#! @python@/bin/python -B +import base64 +import json +import os +import shutil +import socket +import subprocess +import sys + +import yaml + +CONF_FILE = '/etc/qclk/config.yaml' +WG_KEY_FILE = '/etc/qclk/wg.key' + +IWD_PATH = '/var/lib/iwd' +IWD_CONF = '''[Settings] +Hidden={hidden} + +[Security] +Passphrase={psk} +''' + +MANAGEMENT_NET = '''[Match] +Name=management + +[Network] +Address={ip}/32 +''' + +def log(m): + print(m, file=sys.stderr) + +def iwd_ssid_basename(ssid: str) -> str: + if ssid.isalnum() and not any(c in ssid for c in '-_ '): + return ssid + + return f"={ssid.encode('utf-8').hex()}" + +class Configurer: + tmpdir = '/tmp/qclk' + + def __init__(self, conf_file: str, wg_key_file: str): + with open(conf_file) as f: + self.config = yaml.safe_load(f) + + with open(wg_key_file, 'rb') as f: + output = subprocess.check_output(['@wireguardTools@/bin/wg', 'pubkey'], input=f.read()) + pubkey = base64.b64decode(output) + self.id = pubkey[:4].hex() + + os.makedirs(self.tmpdir, exist_ok=True) + + def _setup_hostname(self): + hostname = f'qclk-{self.id}' + log(f"Setting hostname to '{hostname}'") + socket.sethostname(hostname) + + def _setup_wifi(self): + os.makedirs(IWD_CONF, exist_ok=True) + if 'wifi' in self.config: + conf = self.config['wifi'] + tmp = os.path.join(self.tmpdir, 'wifi.psk') + with open(tmp, 'w') as f: + print( + IWD_CONF.format( + hidden=str(conf['hidden']).lower(), + psk=conf['password']), + file=f) + + fname = f"{iwd_ssid_basename(conf['ssid'])}.psk" + log(f"Writing IWD config file '{fname}' for network '{conf['ssid']}'") + shutil.move(tmp, os.path.join(IWD_PATH, fname)) + else: + fname = None + + for f in os.listdir(IWD_PATH): + if (fname is not None and f != fname) and f.endswith('.psk'): + log(f"Cleaning up old IWD config '{f}'") + os.remove(os.path.join(IWD_PATH, f)) + + def _setup_mgmt(self): + conf = self.config['management'] + tmp = os.path.join(self.tmpdir, 'management.network') + with open(tmp, 'w') as f: + print(MANAGEMENT_NET.format(ip=conf['ip']), file=f) + + log(f"Configuring management IP {conf['ip']}") + os.makedirs('/run/systemd/network', exist_ok=True) + shutil.move(tmp, '/run/systemd/network/20-management.network') + subprocess.check_call(['@systemd@/bin/networkctl', 'reload']) + + def reconcile(self): + self._setup_hostname() + self._setup_wifi() + self._setup_mgmt() + +def main(): + c = Configurer(CONF_FILE, WG_KEY_FILE) + c.reconcile() + +if __name__ == '__main__': + main() diff --git a/firmware/default.nix b/firmware/default.nix index 23aea00..d684ba5 100644 --- a/firmware/default.nix +++ b/firmware/default.nix @@ -26,6 +26,7 @@ let ./base.nix ./disk.nix ./network.nix + ./app.nix target ]; @@ -36,49 +37,107 @@ in qclk-rpi3 = mkSystem target/rpi3.nix; }; - perSystem = { libMy, pkgs, ... }: { + perSystem = { lib, libMy, pkgs, ... }: + let + inherit (lib) concatMapStringsSep; + in + { devenv.shells.firmware = libMy.withRootdir { packages = with pkgs; [ nixos-rebuild nixVersions.latest + wireguard-tools ]; - scripts = { + scripts = + let + shellUtils = '' + die() { + echo "$1" >&2 + exit 1 + } + ''; + exportPath = ps: + let pkgsPath = concatMapStringsSep ":" (p: "${p}/bin") ps; in ''export PATH="$PATH:${pkgsPath}"''; + + buildImgRootHelper = pkgs.writeShellScript "fixup-perms" '' + pushd "$1" + # systemd-network GID + chgrp 152 etc/qclk/wg.key + chmod 640 etc/qclk/wg.key + popd + + mkfs.ext4 -L qclkos-persist -d "$1" "$2" + ''; + in + { build.exec = '' nix build "..#nixosConfigurations.qclk-$1.config.system.build.toplevel" ''; build-image.exec = '' set -e - export PATH="$PATH:${pkgs.util-linux}/bin:${pkgs.fakeroot}/bin:${pkgs.e2fsprogs}/bin" - die() { - echo "$1" >&2 - exit 1 + ${exportPath (with pkgs; [ util-linux fakeroot e2fsprogs zstd python3 ])} + ${shellUtils} + usage() { + die "usage: $0 " } - [ -z "$1" ] && die "Need to set target" + populate_persist() { + mkdir -p etc/qclk + + old_umask="$(umask)" + umask 077 + + wg genkey > etc/qclk/wg.key + wg_pubkey="$(wg pubkey < etc/qclk/wg.key)" + id="$(python3 -c "import base64; print(base64.b64decode('$wg_pubkey')[:4].hex())")" + + pin="$(python3 -c "import random; print('''.join(str(random.randint(0, 9)) for _ in range(6)))")" + cat << EOF > etc/qclk/config.yaml + management: + ip: $ip + pin: '$pin' + EOF + umask "$old_umask" + } + + [ $# -eq 2 ] || usage target=$1 - out=qclkos-$target.img + ip=$2 nix build "..#nixosConfigurations.qclk-$target.config.my.disk.image" persistRoot=$(mktemp --tmpdir -d qclkos-persist-XXXXX) - # TODO: bless with unique stuff (e.g. keys) - touch "$persistRoot"/test.txt + pushd "$persistRoot" + populate_persist + popd - cp --sparse=always result/$out $out + out=qclkos-$target-$id.img + cp --sparse=always result/qclkos-$target.img $out chmod u+w $out eval $(partx $out -o START,SECTORS --nr 2 --pairs) persistImg=$(mktemp --tmpdir qclkos-persist-XXXXX.img) truncate -s $((SECTORS * 512)) $persistImg - fakeroot mkfs.ext4 -L qclkos-persist -d $persistRoot $persistImg + fakeroot ${buildImgRootHelper} $persistRoot $persistImg dd conv=notrunc if=$persistImg of=$out seek=$START count=$SECTORS rm -r "$persistRoot" "$persistImg" + [ -z "$NO_COMPRESS" ] && zstd -7 -T0 --rm -f $out + + echo "====== DONE! ======" + echo "WireGuard pubkey: $wg_pubkey" + echo "Control PIN: $pin" ''; push-config.exec = '' + ${shellUtils} + usage() { + die "usage: $0 " + } + [ $# -eq 3 ] || usage + host=$1; shift target=$1; shift verb=$1; shift @@ -86,6 +145,9 @@ in export NIX_SSHOPTS="-i .keys/management.key" nixos-rebuild $verb --flake ..#qclk-$target --target-host root@"$host" --use-substitutes "$@" ''; + clean.exec = '' + rm -f qclkos-*.img* + ''; }; }; }; diff --git a/firmware/disk.nix b/firmware/disk.nix index c862ac0..f4bdeb8 100644 --- a/firmware/disk.nix +++ b/firmware/disk.nix @@ -12,15 +12,16 @@ let compressImage = false; }).overrideAttrs (o: { buildCommand = '' - # HACK: `populateImageCommands` is executed in a subshell _before_ the paths are copied in... + # We need to move the store up a level since we're not using this as a rootfs but as /nix + # HACK: `populateImageCommands` is executed in a subshell _before_ the store paths are copied in... shopt -s expand_aliases - nixUpThenMkfs() { + nixUpThenFaketime() { mv rootImage/{nix/store,} rmdir rootImage/nix faketime "$@" } - alias faketime=nixUpThenMkfs + alias faketime=nixUpThenFaketime ${o.buildCommand} ''; @@ -123,6 +124,8 @@ in directories = [ "/var/lib/nixos" "/var/lib/systemd" + + "/etc/qclk" ]; files = [ "/etc/machine-id" diff --git a/firmware/network.nix b/firmware/network.nix index bad8bdd..f6e3983 100644 --- a/firmware/network.nix +++ b/firmware/network.nix @@ -1,27 +1,52 @@ -{ lib, config, ... }: { +{ lib, config, pkgs, ... }: { + environment = { + systemPackages = with pkgs; [ + wireguard-tools + ]; + }; + networking = { hostName = config.system.name; useDHCP = false; useNetworkd = true; wireless.iwd = { enable = true; - settings.DriverQuirks.DefaultInterface = "*"; + settings = { + # systemd-networkd gets confused if we hop between networks and doesn't redo DHCP + General.EnableNetworkConfiguration = true; + DriverQuirks.DefaultInterface = "*"; + }; }; }; systemd = { network = { wait-online.enable = false; + netdevs = { + "10-management" = { + netdevConfig = { + Name = "management"; + Kind = "wireguard"; + }; + wireguardConfig = { + PrivateKeyFile = "/etc/qclk/wg.key"; + RouteTable = "main"; + }; + wireguardPeers = [ + { + Endpoint = "94.142.240.44:51821"; + PublicKey = "itMQ2DlPEMdJFlIZRQkwa+Mv7cLc9d4zgfzlljEtLn4="; + AllowedIPs = [ "10.100.4.1/32" ]; + PersistentKeepalive = 15; + } + ]; + }; + }; networks = { "10-ethernet" = { matchConfig.Name = "ethernet"; DHCP = "yes"; }; - "10-wifi" = { - matchConfig.Name = "wifi"; - DHCP = "yes"; - networkConfig.IgnoreCarrierLoss = "3s"; - }; }; }; };