From 8496ec73ad133ef926f10f2b6ca251c9f7e8ed65 Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan Date: Sun, 1 Sep 2024 22:19:28 +0100 Subject: [PATCH] firmware: Add ability to load config from USB --- firmware/app.nix | 13 +++- firmware/configurer.py | 135 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/firmware/app.nix b/firmware/app.nix index 99bf786..fccee82 100644 --- a/firmware/app.nix +++ b/firmware/app.nix @@ -5,8 +5,10 @@ let isExecutable = true; python = pkgs.python3.withPackages (ps: with ps; [ pyyaml ]); + utilLinux = pkgs.util-linux; wireguardTools = pkgs.wireguard-tools; systemd = config.systemd.package; + iwd = config.networking.wireless.iwd.package; }; in { @@ -18,14 +20,19 @@ in after = [ "network.target" ]; serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = "${configurer}"; + Type = "notify-reload"; + ExecStart = "${configurer} serve"; }; wantedBy = [ "multi-user.target" ]; }; }; }; + + services = { + udev.extraRules = '' + SUBSYSTEM=="block", ACTION=="add", ENV{ID_BUS}=="usb", ENV{ID_FS_TYPE}=="vfat", RUN+="${configurer} ext-load %E{ID_PATH_TAG} %N" + ''; + }; }; } diff --git a/firmware/configurer.py b/firmware/configurer.py index 9a486e6..76eaacb 100755 --- a/firmware/configurer.py +++ b/firmware/configurer.py @@ -1,11 +1,15 @@ #! @python@/bin/python -B +import argparse import base64 import json import os import shutil +import signal import socket import subprocess import sys +import time +import tomllib import yaml @@ -37,18 +41,34 @@ def iwd_ssid_basename(ssid: str) -> str: return f"={ssid.encode('utf-8').hex()}" class Configurer: - tmpdir = '/tmp/qclk' + tmpdir = '/run/qclk' def __init__(self, conf_file: str, wg_key_file: str): - with open(conf_file) as f: - self.config = yaml.safe_load(f) + self.conf_file = conf_file + self.load_config() 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() + self.load_dir = os.path.join(self.tmpdir, 'load') + self.sd_sock = None + os.makedirs(self.tmpdir, exist_ok=True) + os.makedirs(self.load_dir, exist_ok=True) + + def load_config(self): + with open(self.conf_file) as f: + self.config = yaml.safe_load(f) + + def write_config(self): + log(f'Updaing config') + tmp = os.path.join(self.tmpdir, 'new-config.yaml') + with open(tmp, 'w') as f: + yaml.dump(self.config, f) + + shutil.move(tmp, self.conf_file) def _setup_hostname(self): hostname = f'qclk-{self.id}' @@ -78,6 +98,8 @@ class Configurer: log(f"Cleaning up old IWD config '{f}'") os.remove(os.path.join(IWD_PATH, f)) + subprocess.call(['@iwd@/bin/iwctl', 'station', 'wifi', 'scan']) + def _setup_mgmt(self): conf = self.config['management'] tmp = os.path.join(self.tmpdir, 'management.network') @@ -90,13 +112,118 @@ class Configurer: subprocess.check_call(['@systemd@/bin/networkctl', 'reload']) def reconcile(self): + if self.sd_sock is not None: + self.sd_sock.sendall(b'STATUS=Reconciling configuration...') + self._setup_hostname() self._setup_wifi() self._setup_mgmt() + if self.sd_sock is not None: + self.sd_sock.sendall(b'STATUS=OK\nREADY=1') + + def _ext_load(self, identifier: str, device: str): + mountpoint = os.path.join(self.tmpdir, 'mnt', identifier) + os.makedirs(mountpoint, exist_ok=True) + + try: + log(f'Mounting {device} -> {mountpoint}') + subprocess.check_call(['@utilLinux@/bin/mount', '-t', 'vfat', '-o', 'ro', device, mountpoint]) + try: + path = os.path.join(mountpoint, 'qclk.toml') + if not os.path.exists(path): + log(f'No config file found on {device}') + return + + with open(path, 'rb') as f: + ext_conf = tomllib.load(f) + finally: + subprocess.check_call(['@utilLinux@/bin/umount', mountpoint]) + finally: + os.rmdir(mountpoint) + + if 'wifi' in ext_conf: + log(f'Loading WiFi settings from {device}') + self.config['wifi'] = { + 'ssid': ext_conf['wifi']['ssid'], + 'password': ext_conf['wifi'].get('password', ''), + 'hidden': ext_conf['wifi'].get('hidden', False), + } + self._setup_wifi() + self.write_config() + + def serve(self, args: argparse.Namespace): + os.makedirs(os.path.join(self.tmpdir, 'load'), exist_ok=True) + + addr = os.getenv('NOTIFY_SOCKET') + if addr is not None: + self.sd_sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + if addr[0] == '@': + addr = '\0' + addr[1:] + self.sd_sock.connect(addr) + + running = True + def sighandler(sig, frame): + nonlocal running + if sig in (signal.SIGINT, signal.SIGTERM): + running = False + return + + if sig == signal.SIGHUP: + if self.sd_sock is not None: + t = time.clock_gettime_ns(time.CLOCK_MONOTONIC) // 1000 + self.sd_sock.sendall(f'RELOADING=1\nMONOTONIC_USEC={t}'.encode('ascii')) + self.load_config() + self.reconcile() + elif sig == signal.SIGUSR1: + for identifier in os.listdir(self.load_dir): + fname = os.path.join(self.load_dir, identifier) + with open(fname) as f: + device = f.read().strip() + os.remove(fname) + + try: + self._ext_load(identifier, device) + except Exception as ex: + log(f'Failed to load config from {device}: {ex}') + + for s in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGUSR1]: + signal.signal(s, sighandler) + + self.reconcile() + while running: + signal.pause() + + if self.sd_sock is not None: + self.sd_sock.close() + + def ext_load(self, args: argparse.Namespace): + with open(os.path.join(self.load_dir, args.identifier), 'w') as f: + print(args.device, file=f) + output = subprocess.check_output( + ['@systemd@/bin/systemctl', 'show', '--property', 'MainPID', '--value', 'qclk-configurer.service'], encoding='ascii') + pid = int(output.strip()) + + os.kill(pid, signal.SIGUSR1) + def main(): + parser = argparse.ArgumentParser(description='qclkOS configurer') + cmds = parser.add_subparsers(title='commands', help='reconcile') + + rec_parser = cmds.add_parser('serve') + rec_parser.set_defaults(func=Configurer.serve) + + eload_parser = cmds.add_parser('ext-load') + eload_parser.set_defaults(func=Configurer.ext_load) + eload_parser.add_argument('identifier', help='Unique device name') + eload_parser.add_argument('device', help='Block device path') + + args = parser.parse_args() c = Configurer(CONF_FILE, WG_KEY_FILE) - c.reconcile() + if not hasattr(args, 'func'): + c.serve(args) + else: + args.func(c, args) if __name__ == '__main__': main()