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