diff --git a/.gitignore b/.gitignore index e42b989..91c63d6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,10 @@ __pycache__ *.pyc *.pyo *.pdf -*.temp \ No newline at end of file +*.temp +*.egg-info +/.git + +/build +/dist +/venv diff --git a/README.md b/README.md index 3ef6597..7a9aeba 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,38 @@ -# What? +# Stiefelsystem -Allows you to boot your (or anyone's really) desktop PC with the exact same -hard disk that your laptop is running from, basically exactly as if you'd -unscrew the SSD from your laptop and plug it into the desktop. +The *Stiefelsystem* allows you to use your computer as a **bootable** storage device **for another computer**, you just need a **network connection** between both. -The data transfer to the hard disk goes over a network link. +For proper functionality your system has to be compatible with both devices seamlessly, hence this tool is optimized for **Linux**. -# Why? +Unfortunately, it takes longer than 3 seconds to unscrew the SSD of a Laptop and connect it to the desktop PC, so we wrote this software :smile_cat: -I don't like maintaining multiple operating systems and associated home -folders. +Needed components: +- A computer serving a storage device to to boot from +- Another computer whose CPU/GPU you actually want to use +- A network connection between both +- A USB stick to bootstrap your other computer's startup (no PXE yet) -On the other hand, I like being able to simply use the comfort and power of a -desktop PC if I encounter one. -Unfortunately, on my laptop it takes longer than 3 seconds to unscrew the SSD, -so I wrote this. +## How? -# How? +Instead of unscrewing your SSD of a Laptop and putting it in a Desktop computer to start it there, the *Stiefelsystem* boots up the same system over network while fetching/storing all system files from the Laptop. +Since your system is really running on the desktop, you can of course still access an additional storage device and other hardware installed in the Desktop computer (VR Headsets, ...). -The laptop acts as the server. -It first provides the initrd/kernel via HTTP, -and then the entire block device for its main disk via NBD. -A dedicated high-speed network link is recommended for this (henceforth referred to as: stiefellink). +It works like this: The "server system" (your Laptop) provides the operating system kernel and bootstrapping system over the network to the "client system" (your Desktop computer). +The bootstrapping system on the client then connects to the server again and mapps the whole storage device (your SSD, mapped with NBD), and then mounts it as root filesystem. -I'm using a RTL8156-based 10/100/1G/2.5G USB3.2 NIC on both sides, in a point-to-point topology. -The OS cannot be running on the laptop while it serves the disk, for obvious reasons. -Thus, when the stiefellink NIC is detected by the stiefel-autokexec service, the laptop reboots into -a custom ramdisk which acts as the stiefelsystem server that provides the aforementioned services. -Configuration (IP addresses, block device identifiers, ...) is passed to this server through its kernel cmdline. -The stiefelsystem server will set up the network on stiefellink and wait for requests. +Now you have one computer serving as network disk, the other having that network disk mounted and running on it. -On the desktop PC, a minimal stiefelsystem client ramdisk is booted from a USB flash drive; -again, the configuration comes from the kernel cmdline. -Apart from the cmdline, the ramdisk is actually identical to the server ramdisk. -The steifelsystem client will search for the server on all of its network interfaces until it receives -a correct reply. Once it does, it requests the kernel and initrd that it shall boot, and kexec's into them. -The target system will use a nbd hook in its own initrd to mount the root partition, then boot as usual. -Authentication, encryption and MITM protection happens through a shared symmetric key and AES-EAX. -Your nbd connection itself is unencrypted and unauthenticated, so I strongly recommend a -point-to-point connection and not enabling IP forwarding. +## Communication Flow + ``` time | | | laptop computer desktop computer -| regular OS boot from usb stick or PXE +| regular OS boot live system from USB or PXE | | | | autokexec service | v | discovery message | @@ -72,6 +57,9 @@ v | discovery message | v v ``` +More information can be found in our [more detailed documentation](doc/procedure.md). + + # How to? ## Dependencies @@ -83,66 +71,54 @@ v | discovery message | ## Setup -Basic steps (commands below): - -- You create a Debian-based OS image which is booted on client and server -- This image is flashed on an USB drive, which is used to boot the client -- The same system is kexec'd on your server to serve the root disk +Gist: +- We create a debian based boostrapping system image +- We flash this image on an USB drive, which is used to boot the client +- The same boostrapping image is kexec'd on your server to serve the root disk over network -The scripts in this repo automate all of those task (apart from the thinking...) +Steps: +- visit the config file, but all defaults should be good to go + - the suitable defaults should be set by your Linux distribution in this file! +- `sudo stiefelctl update`: update the bootstrap image +- `sudo stiefelctl create-usbdrive /dev/sdxxx`: flash client boot usb thumbdrive with bootstrap image +- `sudo stiefelctl server`: wait until client connects to serve disks + - or, enable/run `stiefelsystem.service` which just runs `stiefelctl server` -- Make shure you have all dependencies installed - * `cp config-example.yaml config.yaml` and edit it to your wishes. - * Select the modules that are appropriate for your system. -- create the stiefelsystem ramdisk (for use by server and client) - * `sudo ./create-initrd` prepares the debian-based initrd, as a folder and as an archive - * You can check out the initrd with `sudo ./test-nspawn` - * You can check out server and client interactions with `sudo ./test-qemu server` and `sudo ./test-qemu client` +## Development -- Setup the `stiefel-autokexec` service on the laptop (and provide it with the ramdisk and config) and setup the nbd rootfs hook in your initfs on your OS - * `sudo ./setup-server-os` sets up your system, asking for permission for every operation. It sets up: - * The `stiefel-autokexec.service` - * Initrd hooks that can mount your root disk from the network - * A network manager rule to disable control of the network partition network device -- Create a USB boot drive - * `sudo ./setup-client-usbdrive /dev/sdxxx` creates the usb drive +- `stiefelctl test-nspawn` to test the stiefelOS image +- `stiefelctl test-qemu ` to test client-server interactions with virtual machines -- To reset the AES key, run `rm aes-key` (newly created ramdisks won't work with older ones) +- To your secret key, remove `aes-key` at the location specified in the config. # Why don't you use X in the tech stack? -I tried X and it sucks. +We tried X and it sucks. (for some values of X, including but not limited to:) - iSCSI (super-slow compared to `nbd`, and overly complicated) -- PXE (doesn't support my 2.5GBaseT USB NIC) +- PXE (unreliable, USB sticks worked better, but we may revisit :) # Why don't you use Y in the tech stack? We'd really like to use Y, but you haven't implemented support yet. -# Things to improve - -## Creation scripts -- Unify create-initrd-nspawn and create-initrd-cpio -- Allow skipping some of the more time-consuming parts of create-initrd-nspawn -- setup-client-usbdrive: add a script to launch the client script in any linux's userland +# Things to improve ## Network setup - Allow enabling jumbo frames (8192 bytes?) for higher throughput with bigger files - Run a DHCP server on the server (possibly also with PXE support) and support DHCP config on the client -- Allow server to request earlyboot crypto passphrase from the client in its / HTTP GET answer -## Client script +## StiefelOS Client - Search for the server on all network interfaces in parallel, with multiprocessing and network namespaces -## Server script +## StiefelOS Server - Produce warning beeps if not on AC power, especially when battery is low - Re-setup interfaces as they disappear/appear diff --git a/create-initrd b/create-initrd deleted file mode 100755 index fa164d9..0000000 --- a/create-initrd +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import os - -from config import CONFIG as cfg -from util import ( - command, - download_tar, - ensure_root, - get_consent, - initrd_write, - list_files_in_packages, - mount_tmpfs, - umount, - warn, -) - -ensure_root() - -cli = argparse.ArgumentParser() -cli.add_argument('--debian-mirror', default='http://mirror.stusta.de/debian/') -cli.add_argument('--out', default=cfg.path.cpio, help="output cpio file (%(default)s)") -cli.add_argument('--compressor', default=cfg.packing.compressor) -cli.add_argument('--skip-setup', action='store_true') -cli.add_argument('--update', action='store_true') -cli.add_argument('--tmp-work', action='store_true', - help='use a tmpfs for work and cachedir') -args = cli.parse_args() - -# Some random sanity checks -selected_system_modules = sum( - [mod in ('system-gentoo', 'system-arch', 'system-arch-dracut', 'system-gentoo') for mod in cfg.modules]) - -if selected_system_modules != 1: - warn("Please select exactly one system-* module") - raise SystemExit(1) - -if command('uname', '-r', capture_stdout=True).decode()[:-1] not in os.listdir('/lib/modules'): - warn('Modules for running kernel not found in /lib/modules. If you just updated your arch linux, you probably ' - 'should reboot before continuing.\nIf you are not running arch, I don\'t know how you triggered this ' - 'message.\nDo you want to continue?') - if not get_consent(): - raise SystemExit(1) - -if not args.skip_setup: - # discard existing ramdisk content - umount(cfg.path.initrd_devel) - umount(cfg.path.work) - - os.makedirs(cfg.path.work, exist_ok=True) - os.makedirs(cfg.path.cache, exist_ok=True) - - # provide the various tmpfses - if args.tmp_work: - mount_tmpfs(cfg.path.work) - mount_tmpfs(cfg.path.cache) - - deb_packages = [ - 'ifrename', # needed by the payload scripts - 'iproute2', # needed by the payload scripts - 'kexec-tools', # needed for booting the payload system - 'linux-image-amd64', # needed for booting the stiefel system - 'python3', # all payload scripts are written in Python3 - 'python3-pycryptodome', - 'python3-aiohttp', - 'systemd-container', # to allow launching inside nspawn during this script - 'systemd-sysv', # to provide symlinks in /sbin: init, poweroff, ... - ] - - if 'nbd' in cfg.modules: - deb_packages.extend(['nbd-server']) - - if 'lvm' in cfg.modules: - deb_packages.append('lvm2') - - if 'i915' in cfg.modules: - deb_packages.append('firmware-misc-nonfree') - - if cfg.boot.luks_block: - deb_packages.append('cryptsetup') - - deb_packages.extend(cfg.initrd.include_packages) - - # perform initial setup - # this logs to $workdir/$workdir_subpath_initrd/debootstrap/debootstrap.log - # -> usually "workdir/initrd.nspawn/debootstrap/debootstrap.log" - print(f"running debootstrap with log {cfg.path.initrd}/debootstrap/debootstrap.log") - command( - 'debootstrap', - '--include=' + ','.join(deb_packages), - '--cache-dir=' + os.path.abspath(cfg.path.cache), - '--variant=minbase', - '--components=main,contrib,non-free', - '--merged-usr', - '--verbose', - '--merged-usr', - 'stable', - cfg.path.initrd, - args.debian_mirror - ) - -# install our overlay -command('cp', '-RT', 'overlays/initrd', cfg.path.initrd) - -# install the AES key -if not os.path.exists('aes-key'): - print('generating new AES key') - with open('aes-key', 'wb') as fileobj: - fileobj.write(os.urandom(16)) -command('cp', 'aes-key', cfg.path.initrd) - -if not args.skip_setup: - # set root password - command( - 'chpasswd', - nspawn=cfg.path.initrd, - stdin=f'root:{cfg.initrd.password}\n' - ) - - # set root shell - command( - 'chsh', '-s', - cfg.initrd.shell, - nspawn=cfg.path.initrd - ) - - # enable login from systemd-nspawn - initrd_write('/etc/securetty', 'pts/0', append=True) - - # set the hostname - initrd_write('/etc/hostname', 'stiefelsystem') - - # disable lidswitch handling... - initrd_write('/etc/systemd/logind.conf', - 'HandleSuspendKey=ignore', - 'HandleHibernateKey=ignore', - 'HandleLidSwitch=ignore', - 'HandleLidSwitchExternalPower=ignore', - 'HandleLidSwitchDocked=ignore', - append=True - ) - - # systemd configuration - command('systemctl', 'set-default', - 'multi-user.target', - nspawn=cfg.path.initrd - ) - command('systemctl', 'enable', - 'fake-entropy.service', - nspawn=cfg.path.initrd - ) - command('systemctl', 'disable', - 'rsyslog.service', - 'cron.service', - 'networking.service', - 'kexec.service', - 'kexec-load.service', - 'machines.target', - 'remote-fs.target', - nspawn=cfg.path.initrd - ) - command('systemctl', 'mask', - 'serial-getty@.service', - 'apt-daily.timer', - 'apt-daily-upgrade.timer', - 'serial-getty@.service', - 'systemd-journal-flush.service', - 'systemd-timedated.service', - 'systemd-timesyncd.service', - 'systemd-tmpfiles-clean.timer', - 'systemd-update-utmp.service', - 'systemd-update-utmp-runlevel.service', - 'time-sync.target', - nspawn=cfg.path.initrd - ) - - # stiefel configuration - if 'nbd' in cfg.modules: - command('systemctl', 'disable', 'nbd-server.service', nspawn=cfg.path.initrd) - # the nbd server configuration will be generated on-the-fly - - # kernel name - kernel_name = os.readlink(cfg.path.initrd + '/vmlinuz')[13:] - - # create devel overlay system which we'll use to compile a few things - os.makedirs(cfg.path.initrd_devel, exist_ok=True) - os.makedirs(cfg.path.initrd_devel + "-overlayfs-upperdir", exist_ok=True) - os.makedirs(cfg.path.initrd_devel + "-overlayfs-workdir", exist_ok=True) - - command( - "mount", - "-t", "overlay", - "overlay", - "-o", ",".join([ - f"lowerdir={os.path.abspath(cfg.path.initrd)}", - f"upperdir={os.path.abspath(cfg.path.initrd_devel + '-overlayfs-upperdir')}", - f"workdir={os.path.abspath(cfg.path.initrd_devel + '-overlayfs-workdir')}", - ]), - os.path.abspath(cfg.path.initrd_devel), - ) - - command('apt', 'update', nspawn=cfg.path.initrd_devel) - command('apt', 'upgrade', nspawn=cfg.path.initrd_devel) - command('apt', 'install', '-y', - 'build-essential', - 'git', - 'linux-headers-amd64', - 'kmod', - nspawn=cfg.path.initrd_devel - ) - - # compile and install the userland driver for the clevo fan controller - if 'clevo-fancontrol' in cfg.modules: - download_tar( - cfg.mod_config["clevo-fancontrol"].url, - os.path.join(cfg.path.initrd_devel, "root/fancontrol") - ) - command('make', '-C', '/root/fancontrol', nspawn=cfg.path.initrd_devel) - command( - "cp", - cfg.path.initrd_devel + "/root/fancontrol/clevo-fancontrol", - cfg.path.initrd + "/usr/local/bin", - ) - command( - "cp", - cfg.path.initrd_devel + "/root/fancontrol/clevo-fancontrol.service", - cfg.path.initrd + "/etc/systemd/system", - ) - command('systemctl', 'enable', - 'clevo-fancontrol.service', - nspawn=cfg.path.initrd - ) - - if 'r8152' in cfg.modules: - download_tar( - cfg.mod_config["r8152"].url, - os.path.join(cfg.path.initrd_devel, "root/r8152") - ) - - command( - 'make', - '-C', '/root/r8152', - f'KERNELDIR=/lib/modules/{kernel_name}/build', - 'modules', - nspawn=cfg.path.initrd_devel - ) - command( - "cp", - cfg.path.initrd_devel + "/root/r8152/r8152.ko", - cfg.path.initrd + f"/lib/modules/{kernel_name}/kernel/drivers/net/usb/", - ) - os.makedirs(cfg.path.initrd + "/etc/udev/rules.d", exist_ok=True) - command( - "cp", - cfg.path.initrd_devel + "/root/r8152/50-usb-realtek-net.rules", - cfg.path.initrd + "/etc/udev/rules.d/" - ) - command( - "depmod", - "-a", - kernel_name, - nspawn=cfg.path.initrd, - ) - -if args.update: - command('apt', 'update', nspawn=cfg.path.initrd_devel) - command('apt', 'upgrade', nspawn=cfg.path.initrd_devel) - -if 'debug' in cfg.modules: - command('updatedb', nspawn=cfg.path.initrd) - -if not args.skip_setup: - if 'debug' not in cfg.modules: - # strip kernel modules - count = 0 - for path, _, files in os.walk(cfg.path.initrd + '/lib/modules'): - for filename in files: - if filename.endswith('.ko'): - full_path = os.path.join(path, filename) - command('strip', '--strip-debug', full_path, silent=count) - count += 1 - if count > 1: - print(f'$ ... (stripped {count - 1} more modules)') - -# the folder is ready, now we can pack and compress the initrd - -paths_to_exclude = set( - list_files_in_packages(cfg.packing.exclude_packages, cfg.path.initrd) -) -paths_to_exclude.update( - path.encode() for path in cfg.packing.exclude_paths -) - -old_cwd = os.getcwd() -os.chdir(cfg.path.initrd) - -target_files = [] - -def scan_path(path): - for name in os.listdir(path): - full = os.path.normpath(os.path.join(path, name)) - - if full in paths_to_exclude: - continue - if full.endswith(b'__pycache__'): - continue - - yield full - # recurse into directories (don't follow links) - if os.path.isdir(full) and not os.path.islink(full): - yield from scan_path(full) - -print(f'packing {cfg.path.initrd}') - -archive = command( - "bsdcpio", "-0", "-o", "-H", "newc", - stdin=b'\0'.join(scan_path(b'.')) + b'\0', - capture_stdout=True, - env={"LANG": "C"}, -) - -os.chdir(old_cwd) -del old_cwd - -print(f"uncompressed CPIO: {len(archive)} bytes") - -compressed = command( - f"pv -s {len(archive)} | {args.compressor}", - shell=True, - stdin=archive, - capture_stdout=True -) -print(f"compressed CPIO: {len(compressed)} bytes") - -with open(args.out, "wb") as cpiofile: - cpiofile.write(compressed) diff --git a/doc/procedure.md b/doc/procedure.md new file mode 100644 index 0000000..3122eef --- /dev/null +++ b/doc/procedure.md @@ -0,0 +1,35 @@ +# What? + +Stiefelsystem was invented to allow booting your (or anyone's really) desktop PC with the exact same hard disk that your laptop is running from, basically exactly as if you'd unscrew the SSD from your laptop and plug it into the desktop. + +The data transfer to the hard disk goes over a ethernet network link, because everyone has that, it's reliable and dead-simple. + + +# Why? + +We don't like to maintain multiple operating systems and associated home folders, but we still want to profit from the speed, comfort and power of a desktop PC if we encounter one. + +Unfortunately, on most laptops it takes longer than 3 seconds to unscrew the SSD and connect it to the desktop pc, so we wrote this. + + +# How? + +The laptop acts as the server. +It first provides the initrd/kernel via HTTP, +and then the entire block device for its main disk via NBD. +A dedicated high-speed network link is recommended for this (henceforth referred to as: `stiefellink`). + +Ideally, one uses a dedicated point-to-point network connection between both devices. +The OS cannot be running on the laptop in write-mode while it serves the disk to the desktop which also wants to write data. +Thus, when the `stiefellink` NIC is detected by the `stiefel-autokexec.service`, the laptop reboots into a custom ramdisk which acts as the stiefelsystem server that provides the aforementioned services. +Configuration (IP addresses, block device identifiers, ...) is passed to this server through its kernel cmdline. +The stiefelsystem server will set up the network on `stiefellink` and wait for requests. + +On the desktop PC, a minimal stiefelsystem client ramdisk is booted from a USB flash drive; again, the configuration comes from the kernel cmdline. +Apart from the cmdline, the client's bootstrap ramdisk is identical to the server ramdisk. +The stiefelsystem client will search for the server on all of its network interfaces until it receives a correct reply. +Once it does, it requests the kernel and initrd that it shall boot, and kexec's into them. +The target system will use a nbd hook in its own initrd to mount the root partition, then boot as usual. + +Authentication, encryption and MITM protection happens through a shared symmetric key and AES-EAX. +Your nbd connection itself is unencrypted and unauthenticated, so we strongly recommend a point-to-point connection and not enabling IP forwarding. diff --git a/overlays/initrd/etc/systemd/system/console-getty.service.d/override.conf b/overlays/initrd/etc/systemd/system/console-getty.service.d/override.conf deleted file mode 100644 index 7b5fff9..0000000 --- a/overlays/initrd/etc/systemd/system/console-getty.service.d/override.conf +++ /dev/null @@ -1,3 +0,0 @@ -[Service] -ExecStart= -ExecStart=-/sbin/agetty -o '-p -f root -- \\u' --skip-login --noclear --keep-baud console 115200,38400,9600 $TERM diff --git a/overlays/initrd/etc/systemd/system/fake-entropy.service b/overlays/initrd/etc/systemd/system/fake-entropy.service deleted file mode 100644 index 44a4a5f..0000000 --- a/overlays/initrd/etc/systemd/system/fake-entropy.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Add fake entropy to the pool to unblock shit - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/fake-entropy -StandardOutput=journal+console - -[Install] -WantedBy=sysinit.target diff --git a/overlays/initrd/etc/systemd/system/getty@.service.d/override.conf b/overlays/initrd/etc/systemd/system/getty@.service.d/override.conf deleted file mode 100644 index 7b5fff9..0000000 --- a/overlays/initrd/etc/systemd/system/getty@.service.d/override.conf +++ /dev/null @@ -1,3 +0,0 @@ -[Service] -ExecStart= -ExecStart=-/sbin/agetty -o '-p -f root -- \\u' --skip-login --noclear --keep-baud console 115200,38400,9600 $TERM diff --git a/overlays/initrd/init b/overlays/initrd/init deleted file mode 120000 index ebb2a55..0000000 --- a/overlays/initrd/init +++ /dev/null @@ -1 +0,0 @@ -sbin/init \ No newline at end of file diff --git a/overlays/initrd/usr/local/bin/fake-entropy b/overlays/initrd/usr/local/bin/fake-entropy deleted file mode 100755 index e0e3b53..0000000 --- a/overlays/initrd/usr/local/bin/fake-entropy +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import fcntl -import struct - -print("adding fake entropy") - -# from /usr/include/linux/random.h -RNDADDENTROPY = 1074287107 - -# chosen by fair dice roll -rnd = b'\x04' * 512 - -for i in range(8): - rand_pool_info = struct.pack('ii', 8 * len(rnd), len(rnd)) - - fd = os.open("/dev/random", os.O_WRONLY) - fcntl.ioctl(fd, RNDADDENTROPY, rand_pool_info + rnd) - os.close(fd) diff --git a/overlays/initrd/usr/local/bin/stiefel-client b/overlays/initrd/usr/local/bin/stiefel-client deleted file mode 100755 index 324468b..0000000 --- a/overlays/initrd/usr/local/bin/stiefel-client +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/python3 -u - -import base64 -import hashlib -import hmac -import io -import json -import os -import socket -import struct -import subprocess -import sys -import tarfile -import time -import urllib.request - -import Cryptodome.Cipher.AES - -print(f"reading config from kernel cmdline") - -with open('/proc/cmdline') as cmdlinefile: - cmdline = cmdlinefile.read() - -cmdlineargs = {} -for entry in cmdline.strip().split(): - try: - key, value = entry.split('=', maxsplit=1) - cmdlineargs[key] = value - except ValueError: - continue - -print(f"config: {cmdlineargs}") - -with open('/aes-key', 'rb') as keyfile: - KEY = keyfile.read() -KEY_HASH = hashlib.sha256(KEY).hexdigest().encode() -AUTOKEXEC_HMAC_KEY = hashlib.sha256(b'autokexec-reboot/' + KEY).hexdigest().encode() - -# server discovery loop -DISCOVERY_PORT = 61570 -NAMEINFO_FLAGS = socket.NI_NUMERICHOST -sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) -SERVER = None -SERVER_INTERFACE = None -NEED_LUKS = None - - -# TODO: make common code for stiefel-client&server -def encrypt(plaintext): - nonce_gen = hashlib.sha256(plaintext) # recommended by djb lol - nonce_gen.update(os.urandom(16)) - nonce = nonce_gen.digest()[:16] - cipher = Cryptodome.Cipher.AES.new( - KEY, - Cryptodome.Cipher.AES.MODE_EAX, - nonce=nonce, - mac_len=16 - ) - ciphertext, mac = cipher.encrypt_and_digest(plaintext) - if len(mac) != 16: - raise ValueError('bad MAC length') - return nonce + ciphertext + mac - - -def decrypt(blob): - nonce = blob[:16] - ciphertext = blob[16:-16] - mac = blob[-16:] - cipher = Cryptodome.Cipher.AES.new(KEY, Cryptodome.Cipher.AES.MODE_EAX, nonce=nonce, mac_len=16) - decrypted_blob = cipher.decrypt_and_verify(ciphertext, mac) - del nonce, ciphertext, mac - return decrypted_blob - - -while SERVER is None: - # set interfaces up and send discovery messages - for netdev in os.listdir('/sys/class/net'): - try: - with open(f'/sys/class/net/{netdev}/operstate') as state_file: - state = state_file.read().strip() - - if state == 'down': - print(f"setting link up: {netdev!r}") - subprocess.check_call(['ip', 'link', 'set', 'up', netdev]) - elif state == 'up': - with open(f'/sys/class/net/{netdev}/ifindex') as index_file: - idx = int(index_file.read().strip()) - - print(f"broadcasting stiefelsystem discovery message to {netdev!r}") - - sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) - sock.sendto( - b"stiefelsystem:discovery:find-server:" + KEY_HASH, - (f"ff02::1", DISCOVERY_PORT) - ) - except BaseException as exc: - print(f'problem with link {netdev!r}: {exc!r}') - - # receive replies - timeout = time.monotonic() + 1.0 - while True: - remaining = timeout - time.monotonic() - if remaining <= 0: - break - sock.settimeout(remaining) - try: - data, addr = sock.recvfrom(1024) - except socket.timeout: - break - host, _ = socket.getnameinfo(addr, NAMEINFO_FLAGS) - - try: - if data == b"stiefelsystem:discovery:server-hello:" + KEY_HASH: - # test if we can talk to the server on HTTP - SERVER_HTTP_URL = f"http://[{host.replace('%', '%25')}]" - print(f'fetching {SERVER_HTTP_URL}') - request = urllib.request.urlopen(SERVER_HTTP_URL, timeout=1) - meta = json.loads(request.read().decode('utf-8')) - if meta['what'] != 'stiefelsystem-server': - raise ValueError("not a stiefelsystem server!") - SERVER_CHALLENGE = meta['challenge'] - if meta['key-hash'] != KEY_HASH.decode(): - raise ValueError("wrong key hash") - SERVER = host - SERVER_INTERFACE = host.split('%')[1] - NEED_LUKS = meta.get("need-luks") - with open(f'/sys/class/net/{SERVER_INTERFACE}/address') as mac_file: - CLIENT_INTERFACE_MAC = mac_file.read().strip() - break - elif data.startswith(b'stiefelsystem:discovery:autokexec-hello:' + KEY_HASH): - # solve the challenge - print(f'activating autkexec on {host!r}') - challenge = data.split(b':')[-1] - response = hmac.new(AUTOKEXEC_HMAC_KEY, challenge, digestmod='sha256').hexdigest() - sock.sendto( - b'stiefelsystem:discovery:autokexec-reboot:' + KEY_HASH + - b':' + response.encode(), - addr - ) - - except BaseException as exc: - print(f"server {host!r} is broken: {exc!r}") - -boot_req_args = dict() - -if NEED_LUKS: - # use systemd-tty-ask-password-agent - # and wait for input - luks_phrase = subprocess.check_output([ - 'systemd-ask-password', - 'stiefelsystem root block device luks password' - ]).strip() - - luks_enc = encrypt(luks_phrase) - del luks_phrase - boot_req_args["lukspw"] = base64.b64encode(luks_enc).decode() - -# challenge which the server will included in the requested tar file. -# by it we make sure we get a fresh archive just for us. -challenge = base64.b64encode(os.urandom(16)).decode() - -boot_req_args["challenge"] = challenge -requrl = f"{SERVER_HTTP_URL}/boot.tar.aes" -reqdata = json.dumps(boot_req_args).encode() -print(f'fetching {requrl}') - -with urllib.request.urlopen(requrl, reqdata) as bootreq: - blob = bootreq.read() - -if len(blob) < 32: - raise ValueError("corrupted boot.tar.aes") - -print('decrypting boot.tar.aes') -tar_blob = decrypt(blob) -print('decryption done') - -stiefelmodules = [] - -# cmdline arguments for the to-be-stiefeled kernel -# these are supplied by stiefel-server. -inner_cmdline = '' - -with io.BytesIO(tar_blob) as tarfileobj: - with tarfile.open(fileobj=tarfileobj, mode='r') as tar: - # validate challenge response - with tar.extractfile(tar.getmember('challenge')) as fileobj: - challenge_response = fileobj.read().decode() - if challenge_response != challenge: - print(f"challenge response: {challenge_response}") - print(f"expected response: {challenge}") - raise ValueError('bad challenge response - replay attack?') - - # extract this tar file - for member in tar.getmembers(): - with tar.extractfile(member) as fileobj: - data = fileobj.read() - print(f' {member.name}: {len(data)} bytes') - if member.name == 'challenge': - continue - elif member.name == 'cmdline': - # cmdline for the kexec'd real kernel - inner_cmdline = data.decode().strip() - elif member.name == 'stiefelmodules': - # cmdline for the kexec'd real kernel - stiefelmodules = data.decode().split(" ") - else: - with open(f'/{member.name}', 'wb') as fileobj: - fileobj.write(data) - -print('stiefelmodules: %s' % "\n".join(stiefelmodules)) -print('server cmdline: %s' % "\n".join(inner_cmdline.split())) - -# additional inner cmdline arguments given to the stiefel-client-kernel-cmdline -inner_cmdline += ' ' + base64.b64decode(cmdlineargs.get("stiefel_innercmdline", "")).decode() - - -if any(mod in stiefelmodules for mod in ('system-debian', 'system-arch')): - CMDLINE = ( - inner_cmdline + - " stiefel_nbdhost=" + SERVER.replace(SERVER_INTERFACE, "stiefellink") + - " stiefel_nbdname=stiefelblock" + # name is hardcoded in stiefel-server - " stiefel_link=" + CLIENT_INTERFACE_MAC - ) - -elif any(mod in stiefelmodules for mod in ('system-gentoo', 'system-arch-dracut')): - # dracut nbd & network invocation from man dracut.cmdline: - # netroot=nbd:srv:export[:fstype[:rootflags(fsflags)[:nbdopts]]] - # pls sync with test-qemu - CMDLINE = ( - inner_cmdline + - " ifname=stiefellink:" + CLIENT_INTERFACE_MAC + - " ip=stiefellink:link6" + - " netroot=nbd:[" + SERVER.replace(SERVER_INTERFACE, "stiefellink") + "]:stiefelblock:::-persist" - ) -else: - raise Exception("with the given stiefelsystem modules, " - "couldn't construct kexec cmdline") - -print("booting into received kernel, cmdline:") -for cmd in CMDLINE.split(): - print(f"{cmd!r}") - -time.sleep(2) - -subprocess.check_call(['kexec', '/kernel', '--ramdisk=/initrd', '--command-line=' + CMDLINE]) diff --git a/overlays/initrd/usr/local/bin/stiefel-server b/overlays/initrd/usr/local/bin/stiefel-server deleted file mode 100755 index 890071b..0000000 --- a/overlays/initrd/usr/local/bin/stiefel-server +++ /dev/null @@ -1,363 +0,0 @@ -#!/usr/bin/python3 -u - -import aiohttp.web -import base64 -import hashlib -import io -import json -import multiprocessing -import os -import re -import shutil -import socket -import subprocess -import tarfile -import time -import traceback -import urllib - -import Cryptodome.Cipher.AES - -# automatically turn the display off to save power -subprocess.check_call(['setterm', '--powerdown', '1', '--blank', '1']) - -# read config from kernel cmdline -print(f"reading config from kernel cmdline") - -with open('/proc/cmdline') as cmdlinefile: - cmdline = cmdlinefile.read() -cmdlineargs = {} -for entry in cmdline.strip().split(): - try: - key, value = entry.split('=', maxsplit=1) - cmdlineargs[key] = value - except ValueError: - continue - -print(f"config: {cmdlineargs}") - -with open("/aes-key", "rb") as keyfile: - KEY = keyfile.read() -KEY_HASH = hashlib.sha256(KEY).hexdigest().encode() - - -# TODO: make common code for stiefel-client&server -def encrypt(plaintext): - nonce_gen = hashlib.sha256(plaintext) # recommended by djb lol - nonce_gen.update(os.urandom(16)) - nonce = nonce_gen.digest()[:16] - cipher = Cryptodome.Cipher.AES.new( - KEY, - Cryptodome.Cipher.AES.MODE_EAX, - nonce=nonce, - mac_len=16 - ) - ciphertext, mac = cipher.encrypt_and_digest(plaintext) - if len(mac) != 16: - raise ValueError('bad MAC length') - return nonce + ciphertext + mac - - -def decrypt(blob): - nonce = blob[:16] - ciphertext = blob[16:-16] - mac = blob[-16:] - cipher = Cryptodome.Cipher.AES.new(KEY, Cryptodome.Cipher.AES.MODE_EAX, nonce=nonce, mac_len=16) - decrypted_blob = cipher.decrypt_and_verify(ciphertext, mac) - del nonce, ciphertext, mac - return decrypted_blob - - -def discovery_server(): - """ - listens for and responds to discovery multicast messages, - thus providing its IP to interested clients. - - designed to be run in a multiprocessing subprocess. - """ - discovery_port = 61570 # determined by random.choice(range(49152, 2**16)) - nameinfo_flags = socket.NI_NUMERICHOST - - sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(('', discovery_port)) - # allow multicast loopback for development - sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) - - print("discovery server: listening for packets") - while True: - data, addr = sock.recvfrom(1024) - if data != b'stiefelsystem:discovery:find-server:' + KEY_HASH: - continue - host, _ = socket.getnameinfo(addr, nameinfo_flags) - print(f"{host!r} is looking for us") - try: - sock.sendto(b'stiefelsystem:discovery:server-hello:' + KEY_HASH, addr) - except BaseException as exc: - print(f"cannot send discovery reply: {exc!r}") - - -def continuous_network_setup(): - """ - continuously enables all network interfaces as they become available. - - designed to be run in a multiprocessing subprocess. - """ - print('running continuous network setup') - while True: - for netdev in os.listdir('/sys/class/net'): - with open(f'/sys/class/net/{netdev}/operstate') as state_file: - if state_file.read().strip() != 'down': - continue - print(f"setting link up: {netdev!r}") - try: - subprocess.check_call(['ip', 'link', 'set', 'up', netdev]) - except BaseException as exc: - print(f"could not set link up: {exc!r}") - - # instead of time.sleep(), we could use udev like in stiefel-autokexec - time.sleep(0.5 * 20) - - -# challenge for clients to prevent replay attacks -CHALLENGE = base64.b64encode(os.urandom(16)).decode('ascii') - -BLKDEV = cmdlineargs["stiefel_bootdisk"] -BOOTPART_LUKS = cmdlineargs.get("stiefel_bootpart_luks") -BOOTPART = cmdlineargs["stiefel_bootpart"] -UNSECURE = bool(int(cmdlineargs.get("stiefel_unsecure", "0"))) - -multiprocessing.Process(target=continuous_network_setup).start() -multiprocessing.Process(target=discovery_server).start() - -# create NBD config -with open("/etc/nbd-server/config", "w") as nbdconfigfile: - nbdconfigfile.write( - f""" - [generic] - [stiefelblock] - exportname = {BLKDEV} - copyonwrite = false - """ - ) - -# open NBD server -print(f"opening NBD server for {BLKDEV}") -subprocess.check_call(['systemctl', 'start', 'nbd-server']) - - -def read_binary(filename): - if not os.path.isfile(filename): - return None - with open(filename, 'rb') as fileobj: - return fileobj.read() - - -def find_boot_config(): - print('trying to find boot config') - - # kernel hints were manually created for the stiefelsystem - stiefelsystem_config = read_binary("/mnt/stiefelsystem.json") - - ret = { - 'kernel': None, - 'initrd': None, - 'cmdline': None, - 'stiefelmodules': None, - } - - if stiefelsystem_config is not None: - stiefelsystem_config_json = json.loads(stiefelsystem_config) - ret['cmdline'] = " ".join(stiefelsystem_config_json['cmdline']).encode('utf-8') - ret['stiefelmodules'] = " ".join(stiefelsystem_config_json['stiefelmodules']).encode('utf-8') - - kerneldef = stiefelsystem_config_json.get('kernel') - if kerneldef: - ret['kernel'] = os.path.normpath( - b'/mnt/' + kerneldef.encode('utf-8') - ) - - initrddef = stiefelsystem_config_json.get('initrd') - if initrddef: - ret['initrd'] = os.path.normpath( - b'/mnt/' + stiefelsystem_config_json['initrd'].encode('utf-8') - ) - - # we're missing kernel invocation options, try to parse the bootloader config - if not (ret['kernel'] and ret['initrd'] and ret['cmdline']): - # try to determine kernel from syslinux config - syslinux_config = read_binary("/mnt/syslinux/syslinux.cfg") - if syslinux_config is not None: - ret['kernel'] = os.path.normpath(os.path.join( - b'/mnt/syslinux', - re.search(rb'^\s*LINUX\s+(\S+)\s', syslinux_config, re.M).group(1) - )) - ret['initrd'] = os.path.normpath(os.path.join( - b'/mnt/syslinux', - re.search(rb'^\s*INITRD\s+(\S+)\s', syslinux_config, re.M).group(1) - )) - # TODO: cmdline parsing for syslinux cfg - - if not (ret['kernel'] and ret['initrd'] and ret['cmdline']): - # try to determine kernel from grub config - grub_config = read_binary("/mnt/grub/grub.cfg") - if grub_config is not None: - kernelcmd = re.search(rb'^\s*linux\s+(\S+)((?:\s(?:\S+))*)\s*$', grub_config, re.M) - if not kernelcmd: - raise Exception('could not find kernel invocation in grub cfg') - - initrdcmd = re.search(rb'^\s*initrd\s+(\S+)\s', grub_config, re.M) - if not initrdcmd: - raise Exception('could not find initrd definition in grub cfg') - - ret['kernel'] = os.path.normpath( - b'/mnt/' + - kernelcmd.group(1) - ) - ret['initrd'] = os.path.normpath( - b'/mnt/' + - initrdcmd.group(1) - ) - ret['cmdline'] = os.path.normpath( - b'/mnt/' + - kernelcmd.group(2) - ) - - for k, v in ret.items(): - if not v: - raise Exception(f'no configuration found for {k!r}') - - return ret - - -async def generate_boot_tar(payload): - print("reading kernel and initrd") - - challenge = payload['challenge'] - - if BOOTPART_LUKS: - # open luks device to fetch kernel and initrd from it - print("opening luks-crypted device...") - decrypt_mapped = 'stiefelboot' - while os.path.exists(f"/dev/mapper/{decrypt_mapped}"): - decrypt_mapped += '_' - - luks_pw_enc = base64.b64decode(payload['lukspw']) - luks_phrase = decrypt(luks_pw_enc) - - subprocess.run(['cryptsetup', 'open', '--type=luks', - '--key-file=-', - BOOTPART_LUKS, decrypt_mapped], - input=luks_phrase, - check=True) - del luks_phrase - - # force-discover the new pvs and lvs - if shutil.which('lvm'): - subprocess.check_call(['pvscan']) - subprocess.check_call(['lvscan']) - subprocess.check_call(['udevadm', 'settle', - f'--exit-if-exists={BOOTPART}']) - - try: - subprocess.check_call(['mount', '-oro', BOOTPART, '/mnt']) - bootcfg = find_boot_config() - - print(f"kernel: {bootcfg['kernel'].decode(errors='replace')!r}") - kernelblob = read_binary(bootcfg['kernel']) - print(f"initrd: {bootcfg['initrd'].decode(errors='replace')!r}") - initrdblob = read_binary(bootcfg['initrd']) - - finally: - subprocess.check_call(['umount', '/mnt']) - - if BOOTPART_LUKS: - if shutil.which('lvm'): - # deactivate all lvm child blockdevices - blocktree = json.loads(subprocess.check_output( - ['lsblk', '--json', f'/dev/mapper/{decrypt_mapped}']).decode()) - - for block in blocktree['blockdevices'][0]['children']: - if block['type'] == 'lvm': - subprocess.check_call( - ['lvchange', '-an', f"/dev/mapper/{block['name']}"]) - - subprocess.check_call(['cryptsetup', 'close', decrypt_mapped]) - - # create the response TAR in-memory - with io.BytesIO() as fileobj: - with tarfile.open(fileobj=fileobj, mode='w') as tar: - tf = tarfile.TarInfo('kernel') - tf.size = len(kernelblob) - tar.addfile(tf, io.BytesIO(kernelblob)) - - tf = tarfile.TarInfo('initrd') - tf.size = len(initrdblob) - tar.addfile(tf, io.BytesIO(initrdblob)) - - # unique content so a client can detect replay attacks - tf = tarfile.TarInfo('challenge') - challenge = challenge.encode('utf-8') - tf.size = len(challenge) - tar.addfile(tf, io.BytesIO(challenge)) - - cmdline = bootcfg['cmdline'] - tf = tarfile.TarInfo('cmdline') - tf.size = len(cmdline) - tar.addfile(tf, io.BytesIO(cmdline)) - - stiefelmodulelist = bootcfg['stiefelmodules'] - tf = tarfile.TarInfo('stiefelmodules') - tf.size = len(stiefelmodulelist) - tar.addfile(tf, io.BytesIO(stiefelmodulelist)) - - fileobj.seek(0) - return fileobj.read() - - -async def server_infos(request): - return aiohttp.web.json_response({ - "what": "stiefelsystem-server", - "args": cmdlineargs, - "key-hash": KEY_HASH.decode(), - "challenge": CHALLENGE, - "need-luks": bool(BOOTPART_LUKS), - }) - - -async def get_boot_tar_noauth(request): - if UNSECURE: - return aiohttp.web.Response(body=get_boot_tar({'challenge': ""}), - content_type="application/x-binary") - - return aiohttp.web.Response(body="only boot.tar.aes is available", - status=403) - - -async def get_encrypted_boot_tar(request): - """ - generate a boot tar which includes a random challenge the client - gave us so the archive is fresh and - the client can detect replay attacks of boot archives. - """ - payload = await request.json() - - plaintext = await generate_boot_tar(payload) - - print('encrypting boot.tar.aes') - encrypted_blob = encrypt(plaintext) - print('encryption done') - del plaintext - - return aiohttp.web.Response(body=encrypted_blob, - content_type="application/x-binary") - - -print("running HTTP server") - -srv = aiohttp.web.Application() -srv.add_routes([aiohttp.web.get('/', server_infos)]) -srv.add_routes([aiohttp.web.get('/boot.tar', get_boot_tar_noauth)]) -srv.add_routes([aiohttp.web.post('/boot.tar.aes', get_encrypted_boot_tar)]) - -aiohttp.web.run_app(srv, host="::", port=80) diff --git a/overlays/server-os-arch-nbd/etc/initcpio/hooks/nbd b/overlays/server-os-arch-nbd/etc/initcpio/hooks/nbd deleted file mode 100644 index 88ee671..0000000 --- a/overlays/server-os-arch-nbd/etc/initcpio/hooks/nbd +++ /dev/null @@ -1,35 +0,0 @@ -run_hook() { - echo 'waiting for ethernet device' - - i=0 - while [ $i -lt 1000 ]; do - # attempt to rename. the command gives no feedback :( - echo "stiefellink mac ${stiefel_link}" | ifrename -c - - - if [ -e /sys/class/net/stiefellink ]; then - break - fi - sleep 0.05 - i=$(($i+1)) - done - - ip link set up stiefellink - - echo "waiting for server to be reachable" - - i=0 - while [ $i -lt 500 ]; do - if ping -w1 -c1 ${stiefel_nbdhost}; then - break - fi - sleep 0.1 - i=$(($i+1)) - done - - msg "modprobe nbd" - modprobe nbd - msg "nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname}" - nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname} - msg "nbd mount done" - blkid -} diff --git a/overlays/server-os-debian/etc/initramfs-tools/hooks/ifrename b/overlays/server-os-debian/etc/initramfs-tools/hooks/ifrename deleted file mode 100755 index 9e3b793..0000000 --- a/overlays/server-os-debian/etc/initramfs-tools/hooks/ifrename +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -PREREQ="" -prereqs() -{ - echo "$PREREQ" -} - -case $1 in -prereqs) - prereqs - exit 0 - ;; -esac - -# ifrename -. /usr/share/initramfs-tools/hook-functions #provides copy_exec -rm -f ${DESTDIR}/sbin/ifrename #copy_exec won't overwrite an existing file -copy_exec /sbin/ifrename /bin/ifrename #Takes location in filesystem and location in initramfs as arguments diff --git a/overlays/server-os-debian/etc/initramfs-tools/scripts/init-premount/stiefelsystem b/overlays/server-os-debian/etc/initramfs-tools/scripts/init-premount/stiefelsystem deleted file mode 100755 index aed5c15..0000000 --- a/overlays/server-os-debian/etc/initramfs-tools/scripts/init-premount/stiefelsystem +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/sh -PREREQ="" -prereqs() -{ - echo "$PREREQ" -} - -case $1 in -prereqs) - prereqs - exit 0 - ;; -esac - -case "$(cat /proc/cmdline)" in - *stiefel_link*) - echo "we are beeing stiefeled" - - for x in $(cat /proc/cmdline); do - case "$x" in - stiefel_link=*) - stiefel_link="${x#stiefel_link=}" - ;; - stiefel_nbdhost=*) - stiefel_nbdhost="${x#stiefel_nbdhost=}" - ;; - stiefel_nbdname=*) - stiefel_nbdname="${x#stiefel_nbdname=}" - ;; - esac - done - - - - echo 'waiting for ethernet device' - - i=0 - while [ $i -lt 1000 ]; do - # attempt to rename. the command gives no feedback :( - echo "stiefellink mac ${stiefel_link}" | ifrename -c - - - if [ -e /sys/class/net/stiefellink ]; then - break - fi - - sleep 0.05 - i=$(($i+1)) - done - - ip link set up stiefellink - - echo "waiting for server to be reachable" - sleep 4 #choosen by random number generator - - while [ $i -lt 500 ]; do - if ping -w1 -c1 ${stiefel_nbdhost}; then - break - fi - sleep 0.1 - i=$(($i+1)) - done - - echo "modprobe nbd" - modprobe nbd - echo "nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname}" - nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname} - echo "nbd mount done" - blkid - ;; -esac diff --git a/overlays/server-os-generic/etc/systemd/system/stiefel-autokexec.service b/overlays/server-os-generic/etc/systemd/system/stiefel-autokexec.service deleted file mode 100644 index 0aa2645..0000000 --- a/overlays/server-os-generic/etc/systemd/system/stiefel-autokexec.service +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Auto-reboot into Stiefelsystem server on discovery message - -[Service] -Type=simple -ExecStart=/usr/local/bin/stiefel-autokexec - -[Install] -WantedBy=multi-user.target diff --git a/overlays/server-os-generic/usr/local/bin/stiefel-autokexec b/overlays/server-os-generic/usr/local/bin/stiefel-autokexec deleted file mode 100755 index 00c7e74..0000000 --- a/overlays/server-os-generic/usr/local/bin/stiefel-autokexec +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/python3 -u -import base64 -import hmac -import multiprocessing -import json -import os -import shlex -import socket -import subprocess - -import pyudev - -if os.path.exists('/sys/class/net/stiefellink'): - print('skipping stiefel-autokexec because this is already a ' - 'stiefeled system') - raise SystemExit(0) - -with open('/etc/stiefelsystem/config.json') as cfg_fileobj: - config = json.load(cfg_fileobj) - -def command(*cmd): - print(f'$ {" ".join(shlex.quote(part) for part in cmd)}') - subprocess.call(cmd) - -def do_kexec(): - print(f'booting into stiefelsystem server') - - cmdline = [ - f'systemd.unit=stiefel-server.service', - f'stiefel_bootdisk={config["bootdisk"]}', - f'stiefel_bootpart={config["bootpart"]}', - ] - cmdline.extend(config.get("cmdline", [])) - - bootpart_luks = config.get("bootpart-luks") - if bootpart_luks is not None: - cmdline.append( - f'stiefel_bootpart_luks={bootpart_luks}' - ) - - command( - 'kexec', - config['stiefelsystem-kernel'], - '--ramdisk=' + config['stiefelsystem-initrd'], - '--reset-vga', - '--console-vga', - '--command-line=' + ' '.join(cmdline) - ) - -def kexec_on_adapter_found(adapters): - print('waiting for one of these adapters:') - for mac in adapters: - print(f' {mac}') - - context = pyudev.Context() - udev_monitor = pyudev.Monitor.from_netlink(context) - udev_monitor.filter_by(subsystem='net') - while True: - # test if we have the adapter now - for netif in os.listdir('/sys/class/net'): - with open(f'/sys/class/net/{netif}/address') as addrfile: - mac = addrfile.read().strip() - if mac in adapters: - print(f"adapter found: {mac}") - do_kexec() - - # wait until something happens - udev_monitor.poll() - - -def kexec_on_server_discovery_message(aes_key_hash, hmac_key): - discovery_port = 61570 # determined by random.choice(range(49152, 2**16)) - nameinfo_flags = socket.NI_NUMERICHOST - - challenge = base64.b64encode(os.urandom(16)) - - sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(('', discovery_port)) - # allow multicast loopback for development - sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) - - print("kexec-on-server-discovery-message: listening for messages") - while True: - data, addr = sock.recvfrom(1024) - if data == b'stiefelsystem:discovery:find-server:' + aes_key_hash: - host, _ = socket.getnameinfo(addr, nameinfo_flags) - print(f"{host!r} is looking for a stiefelsystem server! challenging it...") - try: - sock.sendto( - b'stiefelsystem:discovery:autokexec-hello:' + aes_key_hash + - b':' + challenge, addr - ) - except BaseException as exc: - print(f"cannot send discovery reply: {exc!r}") - elif data.startswith(b'stiefelsystem:discovery:autokexec-reboot:' + aes_key_hash + b':'): - response = data.split(b':')[-1] - if hmac.compare_digest( - response, - hmac.new(hmac_key, challenge, digestmod='sha256').hexdigest().encode() - ): - # this reboot request is authentic, it solved our challenge - do_kexec() - else: - print(f'bad HMAC signature for autokexec-reboot challenge') - - -if not config['autokexec-triggers']['mac_detection'] and not config['autokexec-triggers']['mac_detection']: - print("no autokexec method enabled. Quitting") - raise SystemExit(1) - -if config['autokexec-triggers']['broadcast']: - multiprocessing.Process( - target=kexec_on_server_discovery_message, - args=(config['aes-key-hash'].encode(), config['hmac-key'].encode()) - ).start() - -if config['autokexec-triggers']['mac_detection']: - kexec_on_adapter_found(config['autokexec-triggers']['adapters']) diff --git a/setup-client-usbdrive b/setup-client-usbdrive deleted file mode 100755 index 4846fc9..0000000 --- a/setup-client-usbdrive +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -""" -script to create the usb flash drive for booting the client -""" -import argparse -import base64 -import os -import tempfile - -from config import CONFIG as cfg -from util import ( - command, - ensure_root, - get_consent, - warn, -) - -ensure_root() - -command('lsblk') - -cli = argparse.ArgumentParser() -cli.add_argument('blockdev') -# some older BIOSes only boot if the boot partition starts at sector 32... -cli.add_argument('--first-sector', type=int, default=32) -args = cli.parse_args() - -if not os.path.exists(args.blockdev): - cli.error(f'block device does not exist: {args.blockdev!r}') - -partition = f'{args.blockdev}1' - -warn(f'wiping entire drive at {args.blockdev!r} to create {partition!r}') - -if not get_consent(): - raise SystemExit(1) - -# TODO create gpt table - -# create partition table and write MBR -command('sfdisk', args.blockdev, stdin=f"label: dos\n{args.first_sector},1638400,c,*\n") -if any(mod in ('system-arch', 'system-arch-dracut') for mod in cfg.modules): - command('dd', 'if=/usr/lib/syslinux/bios/mbr.bin', 'of=' + args.blockdev) -elif 'system-debian' in cfg.modules: - command('dd', 'if=/usr/lib/syslinux/mbr/mbr.bin', 'of=' + args.blockdev) #debian -elif 'system-gentoo' in cfg.modules: - command('dd', 'if=/usr/share/syslinux/mbr.bin', 'of=' + args.blockdev) -else: - print("no system specified in config.yaml modules") - exit(1) - -# create filesystem -command('mkfs.vfat', '-F', '16', partition) -# install bootloader -command('syslinux', partition) - -# mount the filesystem and create the files on it -with tempfile.TemporaryDirectory() as tmpdir: - command('mount', partition, tmpdir) - try: - command( - 'dd', - 'bs=1M', - 'if=' + os.path.join(cfg.path.work, 'initrd.cpio'), - 'of=' + os.path.join(tmpdir, 'initrd'), - 'oflag=direct', - 'status=progress', - ) - command( - 'dd', - 'bs=1M', - 'if=' + os.path.join(cfg.path.initrd, 'vmlinuz'), - 'of=' + os.path.join(tmpdir, 'kernel'), - 'oflag=direct', - 'status=progress', - ) - - cmdline = " ".join([ - # the client system shouldn't modeset. - # if it modesets, then the early boot steps of the actual target - # initrd won't have working video output, making debugging - # them harder. - "nomodeset", - "systemd.unit=stiefel-client.service", - ]) - - with open(os.path.join(tmpdir, 'syslinux.cfg'), 'w') as syslinuxcfg: - syslinuxcfg.write(f"default kernel initrd=initrd {cmdline}\n") - finally: - command('umount', tmpdir) - -print("synching io buffers...") -os.sync() -print("done.") diff --git a/setup-server-os b/setup-server-os deleted file mode 100755 index 1bd4196..0000000 --- a/setup-server-os +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -""" -script to prepare your OS for being stiefeled. -""" -import argparse -import hashlib -import json -import os -import shutil - -from config import CONFIG as cfg -from util import ( - command, - ensure_root, - ensure_unit_enabled, - FileEditor, - install_folder, -) - -ensure_root() -cli = argparse.ArgumentParser() -args = cli.parse_args() - -# ensure that all required tools are installed -if shutil.which('ifrename') is None: - raise RuntimeError("could not find ifrename") -try: - import pyudev - del pyudev -except ImportError: - raise RuntimeError("could not find pyudev") from None - - -if 'base' in cfg.modules: - # base configuration (for all distros) - install_folder('overlays/server-os-generic') - ensure_unit_enabled('stiefel-autokexec.service') - - with open('aes-key', 'rb') as keyfileobj: - KEY = keyfileobj.read() - - stiefel_config = { - 'aes-key-hash': hashlib.sha256(KEY).hexdigest(), - 'hmac-key': hashlib.sha256(b'autokexec-reboot/' + KEY).hexdigest(), - "autokexec-triggers": { - "mac_detectoion": cfg.autokexec.macs, - "broadcast": cfg.autokexec.broadcast, - "adapters": cfg.autokexec.macs, - }, - "bootdisk": cfg.boot.disk, - "bootpart-luks": cfg.boot.luks_block, - "bootpart": cfg.boot.part, - "stiefelsystem-kernel": cfg.server_setup.stiefelsystem_kernel, - "stiefelsystem-initrd": cfg.server_setup.stiefelsystem_initrd, - "cmdline": cfg.server_setup.cmdline, - } - # TODO: rename to autokexec config - edit = FileEditor('/etc/stiefelsystem/config.json') - edit.set_data(json.dumps(stiefel_config, indent=4).encode() + b'\n') - edit.write() - - # copy the special stiefelsystem initrd to its final location - # it will be used for autokexec on the server - edit = FileEditor(cfg.server_setup.stiefelsystem_initrd) - edit.load_from(os.path.join(cfg.path.work, 'initrd.cpio')) - edit.write() - - # same for the debian-generic-kernel - edit = FileEditor(cfg.server_setup.stiefelsystem_kernel) - edit.load_from(os.path.join(cfg.path.initrd, 'vmlinuz')) - edit.write() - -if 'system-arch' in cfg.modules: - # create a new 'stiefel' mkinitcpio-preset - # which will generate a regular arch-initrd - # with stiefel-client mounting support - edit = FileEditor('/etc/mkinitcpio-stiefel.conf') - edit.load_from('/etc/mkinitcpio.conf') - changes = {'`which ifrename`': 'at-end'} - edit.edit_bash_list('BINARIES', changes) - changes = {'amdgpu': 'at-end', 'i915': 'at-end'} - if 'r8152' in cfg.modules: - changes['r8152'] = 'at-end' - edit.edit_bash_list('MODULES', changes) - changes = {'autodetect': 'remove'} - if 'nbd' in cfg.modules: - changes['nbd'] = 'before-fsck' - edit.edit_bash_list('HOOKS', changes) - edit.write() - - edit = FileEditor('/etc/mkinitcpio.d/linux.preset') - edit.load() - edit.edit_bash_list('PRESETS', {'stiefel': 'at-end'}) - edit.add_or_edit_var('stiefel_image', '/boot/initramfs-stiefel.img', add_prefix='\n') - edit.add_or_edit_var('stiefel_options', '-c /etc/mkinitcpio-stiefel.conf -S autodetect') - edit.write() - - # what kernel and initrd is then transferred and kexecd on the stiefel-client - boot_config = { - "kernel": "vmlinuz-linux", - "initrd": "initramfs-stiefel.img", - # TODO: cmdline config option is required now! - "stiefelmodules": list(cfg.modules.keys()), - } - edit = FileEditor('/boot/stiefelsystem.json') - edit.set_data(json.dumps(boot_config, indent=4).encode() + b'\n') - edit.write() - - if 'nbd' in cfg.modules: - install_folder('overlays/server-os-arch-nbd') - - command('mkinitcpio', '-p', 'linux') - -elif 'system-debian' in cfg.modules: - if 'nbd' in cfg.modules: - install_folder('overlays/server-os-debian') - command('update-initramfs', '-u', '-k', 'all') - -elif any(mod in ('system-gentoo', 'system-arch-dracut') for mod in cfg.modules): - # tell stiefel-server which kernel to serve to a stiefel-client, - # relative to /boot - boot_config = { - "kernel": cfg.boot.kernel, - "initrd": cfg.boot.initrd, - "cmdline": cfg.boot.cmdline, - "stiefelmodules": list(cfg.modules.keys()), - } - edit = FileEditor('/boot/stiefelsystem.json') - edit.set_data(json.dumps(boot_config, indent=4).encode() + b'\n') - edit.write() - - if 'nbd' in cfg.modules: - install_folder('overlays/server-os-dracut-nbd') - if 'system-arch-dracut' in cfg.modules: - install_folder('overlays/server-os-dracut-arch') - command('dracut', cfg.boot.initrd, '--add', ' nbd ', '--no-hostonly', '--force') - -else: - raise Exception("no system-specific distro config module is enabled") diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..1f35f70 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + + +setup( + name="stiefelsystem", + version="0.1", + description="Boot your operating system on a different hardware device via network", + long_description=( + "When you have more powerful hardware at hand than e.g. your laptop, " + "and you don't want to maintain two operating systems, this tool " + "may be for you: With the Stiefelsystem, you can start your " + "computer's operating system on another hardware over a simple " + "network link. A link with 1 gbit is sufficient for nearly all use-cases!" + ), + maintainer="SFT Technologies", + maintainer_email="jj@sft.lol", + url="https://github.com/SFTtech/stiefelsystem", + project_urls={ + "Bug Tracker": "https://github.com/SFTtech/stiefelsystem/issues", + }, + license='GPL3+', + python_requires='>=3.9', + packages=find_packages(), + package_data={ + "stiefelsystem": ["etc/*"], + "stiefelsystem.platform": ["files/**/*"], + }, + platforms=[ + 'Linux', + ], + install_requires=[ + 'pyyaml', + 'aiohttp', + 'pycryptodome', + ], + classifiers=[ + ("License :: OSI Approved :: " + "GNU General Public License v3 or later (GPLv3+)"), + "Environment :: Console", + "Operating System :: POSIX :: Linux" + ], + entry_points={ + 'console_scripts': [ + 'stiefelctl=stiefelsystem.main:main', + ] + }, +) diff --git a/stiefelctl b/stiefelctl new file mode 100755 index 0000000..b34463d --- /dev/null +++ b/stiefelctl @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +""" +stiefelsystem control tool +""" + +import stiefelsystem.main + + +if __name__ == "__main__": + stiefelsystem.main.main() diff --git a/stiefelsystem/__init__.py b/stiefelsystem/__init__.py new file mode 100644 index 0000000..2dfc4bb --- /dev/null +++ b/stiefelsystem/__init__.py @@ -0,0 +1,5 @@ +""" +stiefelsystem - autodiscovering network drives + +boot your system on another device over a network cable! +""" diff --git a/stiefelsystem/__main__.py b/stiefelsystem/__main__.py new file mode 100644 index 0000000..26e6b54 --- /dev/null +++ b/stiefelsystem/__main__.py @@ -0,0 +1,9 @@ +""" +stiefelsystem launch entry point +""" + +from .main import main + + +if __name__ == "__main__": + main() diff --git a/stiefelsystem/cli.py b/stiefelsystem/cli.py new file mode 100644 index 0000000..8e30013 --- /dev/null +++ b/stiefelsystem/cli.py @@ -0,0 +1,95 @@ +""" +all available cli entry points +""" + +import argparse +import importlib.resources +import os +from pathlib import Path + +from .client import Client +from .hostos import HostOSSetup +from .nspawn import NSpawnTest +from .qemu import QemuTest +from .server import Server +from .stiefelos.creator import StiefelOSCreator +from .stiefelos.launch import StiefelOSLauncher +from .subcommand import ConfigDefault +from .update import Updater +from .usbdrive import USBDriveCreator + + +def parse_args(system_mode=False): + """ + stiefelsystem has many subcommands: + - launch stiefelos as client or server + - for testing with qemu + - create stiefelos image + """ + + if system_mode: + # the python module is installed - use the system-wide config file by default + config_location = "/etc/stiefelsystem/config.yaml" + else: + # use the in-repo location + package_name = __name__.split(".", maxsplit=1)[0] + package_location = Path(importlib.resources.files(package_name)) + config_location = (package_location.parent / "config.yaml").relative_to(os.getcwd()) + + # argument definitions + cli = argparse.ArgumentParser(description='stiefelsystem - the network boot system') + + cli.add_argument("-v", "--verbose", action="count", default=0, + help="increase program verbosity") + cli.add_argument("-q", "--quiet", action="count", default=0, + help="decrease program verbosity") + + cli.add_argument("-c", "--config-file", default=config_location, + help="path to main configuration file (default: %(default)s)") + + sp = cli.add_subparsers(dest='mode') + sp.required = True + + def register_parser(modename, classname, help=None): + subparser = sp.add_parser(modename, help=help) + classname.register(subparser) + + register_parser('update', Updater, + help="auto-update your stiefelsystem installation") + register_parser('setup-host-os', HostOSSetup, + help="install files to enable stiefelsystem on your host system") + register_parser('create-stiefelos', StiefelOSCreator, + help="create the stiefelOS system image") + register_parser('create-usbdrive', USBDriveCreator, + help="write startup data to a usb drive") + register_parser('launch-stiefelos', StiefelOSLauncher, + help="replace your current system with stiefelOS by kexec") + register_parser('test-nspawn', NSpawnTest, + help="run nspawn for stiefelOS development") + register_parser('test-qemu', QemuTest, + help="run QEMU for stiefelsystem development") + register_parser('server', Server, + help="serve disks over the stiefelsystem protocol") + register_parser('client', Client, + help="access and boot discs over the stiefelsystem protocol") + + args = cli.parse_args() + + # if registered in a subparser, check args for consistency + if hasattr(args, "checkfunc"): + args.checkfunc(args) + + return args + + +def set_config_defaults(args, config): + """ + scan all the args and replace special ConfigDefault values + with the configuration file value. + """ + replacements = dict() + for member, value in vars(args).items(): + if isinstance(value, ConfigDefault): + replacements[member] = value.get_value(config) + + vars(args).update(replacements) diff --git a/stiefelsystem/client.py b/stiefelsystem/client.py new file mode 100644 index 0000000..353e868 --- /dev/null +++ b/stiefelsystem/client.py @@ -0,0 +1,251 @@ +""" +stiefelsystem client code. +this tool connects to a stiefel-server. +""" + +import base64 +import hashlib +import hmac +import io +import json +import os +import socket +import struct +import subprocess +import sys +import tarfile +import time +import urllib.request + +from .crypto import encrypt, decrypt +from .subcommand import Subcommand + + +class Client(Subcommand): + """ + Run stiefelsystem in server mode. + """ + + @classmethod + def register(cls, cli): + cli.add_argument("-c", "--config", + help="path to config file") + cli.add_argument("--no-nbd", action="store_true", + help="do not generate a config file for nbd-server") + cli.add_argument("--add-cmd", + help="arguments to add to the served cmdline") + cli.add_argument("cmdline", nargs="+", + help="cmdline args. by default, is /proc/cmdline contents") + cli.set_defaults(subcommand=cls) + + + def run(self, args, cfg): + """ + create an initrd suitable for running stiefel-server or stiefel-client + """ + + ensure_root() + + print(f"reading config from kernel cmdline") + + if args.cmdline: + cmdlinerawargs = args.cmdline + else: + with open('/proc/cmdline') as cmdlinefile: + cmdline = cmdlinefile.read() + cmdlinerawargs = cmdline.strip().split() + + cmdlineargs = {} + for entry in cmdlinerawargs: + try: + key, value = entry.split('=', maxsplit=1) + cmdlineargs[key] = value + except ValueError: + continue + + print(f"config: {cmdlineargs}") + + with open('/aes-key', 'rb') as keyfile: + KEY = keyfile.read() + KEY_HASH = hashlib.sha256(KEY).hexdigest().encode() + AUTOKEXEC_HMAC_KEY = hashlib.sha256(b'autokexec-reboot/' + KEY).hexdigest().encode() + + # server discovery loop + DISCOVERY_PORT = 61570 + NAMEINFO_FLAGS = socket.NI_NUMERICHOST + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + SERVER = None + SERVER_INTERFACE = None + NEED_LUKS = None + + while SERVER is None: + # set interfaces up and send discovery messages + for netdev in os.listdir('/sys/class/net'): + try: + with open(f'/sys/class/net/{netdev}/operstate') as state_file: + state = state_file.read().strip() + + if state == 'down': + print(f"setting link up: {netdev!r}") + subprocess.check_call(['ip', 'link', 'set', 'up', netdev]) + elif state == 'up': + with open(f'/sys/class/net/{netdev}/ifindex') as index_file: + idx = int(index_file.read().strip()) + + print(f"broadcasting stiefelsystem discovery message to {netdev!r}") + + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, idx) + sock.sendto( + b"stiefelsystem:discovery:find-server:" + KEY_HASH, + (f"ff02::1", DISCOVERY_PORT) + ) + except BaseException as exc: + print(f'problem with link {netdev!r}: {exc!r}') + + # receive replies + timeout = time.monotonic() + 1.0 + while True: + remaining = timeout - time.monotonic() + if remaining <= 0: + break + sock.settimeout(remaining) + try: + data, addr = sock.recvfrom(1024) + except socket.timeout: + break + host, _ = socket.getnameinfo(addr, NAMEINFO_FLAGS) + + try: + if data == b"stiefelsystem:discovery:server-hello:" + KEY_HASH: + # test if we can talk to the server on HTTP + SERVER_HTTP_URL = f"http://[{host.replace('%', '%25')}]:4644" + print(f'fetching {SERVER_HTTP_URL}') + request = urllib.request.urlopen(SERVER_HTTP_URL, timeout=1) + meta = json.loads(request.read().decode('utf-8')) + if meta['what'] != 'stiefelsystem-server': + raise ValueError("not a stiefelsystem server!") + SERVER_CHALLENGE = meta['challenge'] + if meta['key-hash'] != KEY_HASH.decode(): + raise ValueError("wrong key hash") + SERVER = host + SERVER_INTERFACE = host.split('%')[1] + NEED_LUKS = meta.get("need-luks") + with open(f'/sys/class/net/{SERVER_INTERFACE}/address') as mac_file: + CLIENT_INTERFACE_MAC = mac_file.read().strip() + break + elif data.startswith(b'stiefelsystem:discovery:autokexec-hello:' + KEY_HASH): + # solve the challenge + print(f'activating autkexec on {host!r}') + challenge = data.split(b':')[-1] + response = hmac.new(AUTOKEXEC_HMAC_KEY, challenge, digestmod='sha256').hexdigest() + sock.sendto( + b'stiefelsystem:discovery:autokexec-reboot:' + KEY_HASH + + b':' + response.encode(), + addr + ) + + except BaseException as exc: + print(f"server {host!r} is broken: {exc!r}") + + boot_req_args = dict() + + if NEED_LUKS: + # use systemd-tty-ask-password-agent + # and wait for input + luks_phrase = subprocess.check_output([ + 'systemd-ask-password', + 'stiefelsystem root block device luks password' + ]).strip() + + luks_enc = encrypt(KEY, luks_phrase) + del luks_phrase + boot_req_args["lukspw"] = base64.b64encode(luks_enc).decode() + + # challenge which the server will included in the requested tar file. + # by it we make sure we get a fresh archive just for us. + challenge = base64.b64encode(os.urandom(16)).decode() + + boot_req_args["challenge"] = challenge + requrl = f"{SERVER_HTTP_URL}/boot.tar.aes" + reqdata = json.dumps(boot_req_args).encode() + print(f'fetching {requrl}') + + with urllib.request.urlopen(requrl, reqdata) as bootreq: + blob = bootreq.read() + + if len(blob) < 32: + raise ValueError("corrupted boot.tar.aes") + + print('decrypting boot.tar.aes') + tar_blob = decrypt(KEY, blob) + print('decryption done') + + stiefelmodules = [] + + # cmdline arguments for the to-be-stiefeled kernel + # these are supplied by stiefel-server. + inner_cmdline = '' + + with io.BytesIO(tar_blob) as tarfileobj: + with tarfile.open(fileobj=tarfileobj, mode='r') as tar: + # validate challenge response + with tar.extractfile(tar.getmember('challenge')) as fileobj: + challenge_response = fileobj.read().decode() + if challenge_response != challenge: + print(f"challenge response: {challenge_response}") + print(f"expected response: {challenge}") + raise ValueError('bad challenge response - replay attack?') + + # extract this tar file + for member in tar.getmembers(): + with tar.extractfile(member) as fileobj: + data = fileobj.read() + print(f' {member.name}: {len(data)} bytes') + if member.name == 'challenge': + continue + elif member.name == 'cmdline': + # cmdline for the kexec'd real kernel + inner_cmdline = data.decode().strip() + elif member.name == 'stiefelmodules': + # cmdline for the kexec'd real kernel + stiefelmodules = data.decode().split(" ") + else: + with open(f'/{member.name}', 'wb') as fileobj: + fileobj.write(data) + + print('stiefelmodules:\n%s' % "\n".join(stiefelmodules)) + print('inner cmdline from stiefel-server:\n%s' % "\n".join(inner_cmdline.split())) + + # additional inner cmdline arguments given to the stiefel-client-kernel-cmdline + inner_cmdline += ' ' + base64.b64decode(cmdlineargs.get("stiefel_innercmdline", "")).decode() + + + if any(mod in stiefelmodules for mod in ('system-debian', 'system-arch')): + CMDLINE = ( + inner_cmdline + + " stiefel_nbdhost=" + SERVER.replace(SERVER_INTERFACE, "stiefellink") + + " stiefel_nbdname=stiefelblock" + # name is hardcoded in stiefel-server + " stiefel_link=" + CLIENT_INTERFACE_MAC + ) + + elif any(mod in stiefelmodules for mod in ('system-gentoo', 'system-arch-dracut')): + # dracut nbd & network invocation from man dracut.cmdline: + # netroot=nbd:srv:export[:fstype[:rootflags(fsflags)[:nbdopts]]] + # pls sync with test-qemu + CMDLINE = ( + inner_cmdline + + " ifname=stiefellink:" + CLIENT_INTERFACE_MAC + + " ip=stiefellink:link6" + + " netroot=nbd:[" + SERVER.replace(SERVER_INTERFACE, "stiefellink") + "]:stiefelblock:::-persist" + ) + else: + raise Exception("with the given stiefelsystem modules, " + "couldn't construct kexec cmdline") + + print("booting into received kernel, cmdline:") + for cmd in CMDLINE.split(): + print(f"{cmd!r}") + + kexec_invoc = ['kexec', '/kernel', '--ramdisk=/initrd', '--command-line=' + CMDLINE] + print('running kexec: %s' % kexec_invoc) + subprocess.check_call(kexec_invoc) diff --git a/config.py b/stiefelsystem/config.py similarity index 62% rename from config.py rename to stiefelsystem/config.py index a8440df..0c0f862 100644 --- a/config.py +++ b/stiefelsystem/config.py @@ -5,10 +5,13 @@ See config-example.yaml for an example configuration. """ +import logging import os import yaml +from pathlib import Path + def ensure_bool(obj): """ raises an exception if obj is not bool """ @@ -47,29 +50,57 @@ class Config: Global configuration object; contains sub-objects for the various configurable aspects. """ - def __init__(self, filename): - with open(filename) as config_fileobj: - raw = yaml.load(config_fileobj, Loader=yaml.SafeLoader) + module_config_classes = dict() + + def __init__(self, cfgfilename, system_mode=False): + # whether stiefelsystem runs in its repo or is installed system-wide + self.system_mode = system_mode + + logging.info("reading config file %s...", cfgfilename) + + with open(cfgfilename) as config_fileobj: + raw = yaml.safe_load(config_fileobj) + + stiefel_cfg = raw['stiefelsystem'] self.mod_config = {} - for module, module_raw in raw['module-configs'].items(): - self.mod_config[module] = MODULE_CONFIG_CLASSES[module](module_raw) + for module, module_raw in stiefel_cfg['module-configs'].items(): + logging.debug(f"processing module config for {module!r}") + self.mod_config[module] = self.module_config_classes[module](module_raw) self.modules = {} - for module in ensure_stringlist(raw['modules']): + for module in ensure_stringlist(stiefel_cfg['modules']): self.modules[module] = self.mod_config.get(module) + self.boot = BootConfig(raw['boot']) self.autokexec = AutoKexecConfig(raw['autokexec']) self.server_setup = ServerSetupConfig(raw['server-setup']) self.initrd = InitRDConfig(raw['initrd']) self.packing = PackingConfig(raw['initrd']['packing']) - self.path = PathConfig(raw['paths']) + self.path = PathConfig(stiefel_cfg['paths']) + + self.aes_key_location = self.path.state / "aes-key" for mod_config in self.modules.values(): if mod_config: mod_config.apply(self) + @classmethod + def module_config(cls, module_name): + """ + Decorator function to be used with module configs + Enters the class into `module_config_classes` + + Module config classes must provide an 'apply' method which will be + automatically called after the config has finished parsing, + if the module is enabled. It will be passed the config as an argument. + """ + def register_module(module_class): + cls.module_config_classes[module_name] = module_class + return module_class + return register_module + class BootConfig: """ @@ -90,9 +121,9 @@ def __init__(self, raw): else: raise ValueError(f"unknown boot disk method {self.method!r}") - self.kernel = ensure_string(raw['load']['kernel']) - self.initrd = ensure_string(raw['load']['initrd']) - self.cmdline = ensure_stringlist(raw['load']['cmdline']) + self.kernel = ensure_string(raw['kernel']) + self.initrd = ensure_string(raw['initrd']) + self.cmdline = ensure_stringlist(raw['cmdline']) class AutoKexecConfig: @@ -108,14 +139,13 @@ def __init__(self, raw): self.macs = [] - class ServerSetupConfig: """ server system setup information """ def __init__(self, raw): - self.stiefelsystem_kernel = ensure_string(raw['stiefelsystem-kernel']) - self.stiefelsystem_initrd = ensure_string(raw['stiefelsystem-initrd']) + self.stiefel_os_kernel = ensure_string(raw['stiefel-os-kernel']) + self.stiefel_os_initrd = ensure_string(raw['stiefel-os-initrd']) self.cmdline = ensure_stringlist(raw['cmdline']) @@ -144,38 +174,29 @@ class PathConfig: The various internal paths where the scripts operate. """ def __init__(self, raw): - self.cache = ensure_string(raw['cache']) - self.work = ensure_string(raw['workdir']) + # debootstrap downloads + # TODO on debianoid systems, we could share it with the system + # package cache + self.cache = Path(ensure_string(raw['cache_dir'])) - self.workpaths = { - key: os.path.join(self.work, ensure_string(value)) - for key, value in raw['workdir-subpaths'].items() - } + # where the resulting initramfs is stored + self.state = Path(ensure_string(raw['state_dir'])) - self.cpio = self.workpaths['cpio'] - self.initrd = self.workpaths['initrd'] - self.initrd_devel = self.workpaths['initrd-devel'] + # workdir, we create stiefelOS in here + self.work = self.state / "work" + # where the system is assembled + self.initrd = self.work / "initrd" -def module_config(module_name): - """ - Decorator function to be used with module configs - Enters the class into MODULE_CONFIG_CLASSES + # overlayfs mounted over initrd, where additional development tools + # are installed + self.initrd_devel = self.work / "initrd-devel" - Module config classes must provide an 'apply' method which will be - automatically called after the config has finished parsing, - if the module is enabled. It will be passed the config as an argument. - """ - def register_module(module_class): - MODULE_CONFIG_CLASSES[module_name] = module_class - return module_class - return register_module + # the packed initrd directory as archive + self.cpio = self.work / "initrd.cpio" -# see module_config(). -MODULE_CONFIG_CLASSES = {} - -@module_config("debug") +@Config.module_config("debug") class ModuleConfigDebug: def __init__(self, raw): self.better_shell = ensure_string(raw['better-shell']) @@ -185,8 +206,10 @@ def __init__(self, raw): self.extra_packages = ensure_stringlist(raw['extra-packages']) def apply(self, cfg): - # modify the initrd creation and packing configuration to install more - # utilities, increasing the initrd usability + """ + modify the initrd creation and packing configuration to install more + utilities, increasing the initrd usability + """ cfg.initrd.include_packages.extend(self.extra_packages) cfg.initrd.shell = self.better_shell if self.dont_exclude_paths: @@ -194,16 +217,3 @@ def apply(self, cfg): if self.dont_exclude_packages: cfg.packing.exclude_packages.clear() cfg.packing.compressor = self.faster_compressor - - -@module_config("clevo-fancontrol") -@module_config("r8152") -class ModuleConfigGenericURL: - def __init__(self, raw): - self.url = ensure_string(raw['url']) - - def apply(self, cfg): - pass - -# load the config on module initialization -CONFIG = Config('config.yaml') diff --git a/stiefelsystem/crypto.py b/stiefelsystem/crypto.py new file mode 100644 index 0000000..5bdd7e3 --- /dev/null +++ b/stiefelsystem/crypto.py @@ -0,0 +1,46 @@ +""" +cryptographic helper functions +""" + +import os +import hashlib + +from Crypto.Cipher import AES + +def encrypt(key, plaintext): + """ + encrypt a given plaintext blob with a key. + the returned blob is a nonce, the ciphertext and a mac. + """ + nonce_gen = hashlib.sha256(plaintext) # recommended by djb lol + nonce_gen.update(os.urandom(16)) + nonce = nonce_gen.digest()[:16] + cipher = AES.new( + key, + AES.MODE_EAX, + nonce=nonce, + mac_len=16 + ) + ciphertext, mac = cipher.encrypt_and_digest(plaintext) + if len(mac) != 16: + raise ValueError('bad MAC length') + return nonce + ciphertext + mac + + +def decrypt(key, blob): + """ + decrypt a given blob with a key. + the blob has to include a nonce, mac and ciphertext. + """ + nonce = blob[:16] + ciphertext = blob[16:-16] + mac = blob[-16:] + cipher = AES.new( + key, + AES.MODE_EAX, + nonce=nonce, + mac_len=16 + ) + decrypted_blob = cipher.decrypt_and_verify(ciphertext, mac) + del nonce, ciphertext, mac + return decrypted_blob diff --git a/stiefelsystem/etc/stiefelsystem-autolaunch.service b/stiefelsystem/etc/stiefelsystem-autolaunch.service new file mode 100644 index 0000000..263b859 --- /dev/null +++ b/stiefelsystem/etc/stiefelsystem-autolaunch.service @@ -0,0 +1,9 @@ +[Unit] +Description=Autostart StiefelOS when client seeks this server + +[Service] +Type=simple +ExecStart=/usr/bin/stiefelctl autolaunch-stiefelos + +[Install] +WantedBy=multi-user.target diff --git a/stiefelsystem/etc/stiefelsystem.service b/stiefelsystem/etc/stiefelsystem.service new file mode 100644 index 0000000..2b11fd1 --- /dev/null +++ b/stiefelsystem/etc/stiefelsystem.service @@ -0,0 +1,9 @@ +[Unit] +Description=Serve disks via Stiefelsystem + +[Service] +Type=simple +ExecStart=/usr/bin/stiefelctl server + +[Install] +WantedBy=multi-user.target diff --git a/stiefelsystem/hostos.py b/stiefelsystem/hostos.py new file mode 100644 index 0000000..b27af89 --- /dev/null +++ b/stiefelsystem/hostos.py @@ -0,0 +1,37 @@ +""" +script to prepare your OS for being stiefeled. +""" +from pathlib import Path + +from .subcommand import Subcommand +from .platform import nbd + + +class HostOSSetup(Subcommand): + """ + Setup your regular OS so it can be served by StiefelOS server. + + This can either be used directly by a user + or when creating a stiefelsystem distribution package. + + All steps here must be possible without any dynamic configuration, + since this is what can be executed when packaging stiefelsystem. + """ + + @classmethod + def register(cls, cli): + cli.add_argument("-p", "--prefix", default="/", + help="filesystem prefix to use for installing files") + + cli.set_defaults(subcommand=cls) + + def checkfunc(args): + args.prefix = Path(args.prefix) + cli.set_defaults(checkfunc=checkfunc) + + def run(self, args, cfg): + if 'nbd' in cfg.modules: + nbd.install(args.prefix, cfg) + + else: + raise RuntimeError("no disk transport mechanism enabled") diff --git a/stiefelsystem/main.py b/stiefelsystem/main.py new file mode 100644 index 0000000..f076bb3 --- /dev/null +++ b/stiefelsystem/main.py @@ -0,0 +1,45 @@ +""" +stiefelsystem main entry point +""" + +import argparse +from pathlib import Path +import importlib.resources +import sysconfig + + +from .cli import parse_args, set_config_defaults +from .config import Config +from .util import log_setup + + +def main(): + """ + parse and launch requested feature + """ + + # detect if stiefelsystem is running in its repo, or globally + module_location = Path(importlib.resources.files("stiefelsystem")) + sys_mod_path = Path(sysconfig.get_path("purelib")) + system_mode = module_location.is_relative_to(sys_mod_path) + + args = parse_args() + + # adjust log level + log_setup(args.verbose - args.quiet) + + # instance the subcommand class + subcommand = args.subcommand() + + # parse and evaluate config file, + # and use args to override available config fields + config = Config(args.config_file, system_mode) + + # for arguments that shall take their defaults from the config file + # resolve the values here. + set_config_defaults(args, config) + + # run the subcommand + ret = subcommand.run(args, config) + + exit(ret) diff --git a/stiefelsystem/nspawn.py b/stiefelsystem/nspawn.py new file mode 100644 index 0000000..a3ed4b1 --- /dev/null +++ b/stiefelsystem/nspawn.py @@ -0,0 +1,27 @@ +import argparse + +from .subcommand import Subcommand +from .util import ( + command, + ensure_root, +) + + +class NSpawnTest(Subcommand): + """ + Run the StiefelOS image in systemd-nspawn. + """ + + @classmethod + def register(cls, cli): + cli.add_argument('--target', default=ConfigDefault("path.initrd")) + cli.set_defaults(subcommand=cls) + + def run(self, args, cfg): + """ + launch nspawn inside the stiefelOS image. + """ + + ensure_root() + + command('-b', nspawn=args.target) diff --git a/stiefelsystem/platform/__init__.py b/stiefelsystem/platform/__init__.py new file mode 100644 index 0000000..36850b6 --- /dev/null +++ b/stiefelsystem/platform/__init__.py @@ -0,0 +1,6 @@ +""" +stiefelsystem platform specific files + +each linux distribution and tool needs its special configuration. +they are implemented here. +""" diff --git a/overlays/server-os-dracut-arch/etc/pacman.d/hooks/91-dracut-stiefel-remove.hook b/stiefelsystem/platform/files/arch_dracut/etc/pacman.d/hooks/91-dracut-stiefel-remove.hook similarity index 100% rename from overlays/server-os-dracut-arch/etc/pacman.d/hooks/91-dracut-stiefel-remove.hook rename to stiefelsystem/platform/files/arch_dracut/etc/pacman.d/hooks/91-dracut-stiefel-remove.hook diff --git a/overlays/server-os-dracut-arch/etc/pacman.d/hooks/92-dracut-stiefel-install.hook b/stiefelsystem/platform/files/arch_dracut/etc/pacman.d/hooks/92-dracut-stiefel-install.hook similarity index 100% rename from overlays/server-os-dracut-arch/etc/pacman.d/hooks/92-dracut-stiefel-install.hook rename to stiefelsystem/platform/files/arch_dracut/etc/pacman.d/hooks/92-dracut-stiefel-install.hook diff --git a/overlays/server-os-dracut-arch/usr/bin/dracut-stiefel-install.sh b/stiefelsystem/platform/files/arch_dracut/usr/bin/dracut-stiefel-install.sh similarity index 100% rename from overlays/server-os-dracut-arch/usr/bin/dracut-stiefel-install.sh rename to stiefelsystem/platform/files/arch_dracut/usr/bin/dracut-stiefel-install.sh diff --git a/overlays/server-os-dracut-arch/usr/bin/dracut-stiefel-remove.sh b/stiefelsystem/platform/files/arch_dracut/usr/bin/dracut-stiefel-remove.sh similarity index 100% rename from overlays/server-os-dracut-arch/usr/bin/dracut-stiefel-remove.sh rename to stiefelsystem/platform/files/arch_dracut/usr/bin/dracut-stiefel-remove.sh diff --git a/overlays/server-os-dracut-nbd/etc/dracut.conf.d/60-stiefelsystem.conf b/stiefelsystem/platform/files/dracut_nbd/etc/dracut.conf.d/60-stiefelsystem.conf similarity index 100% rename from overlays/server-os-dracut-nbd/etc/dracut.conf.d/60-stiefelsystem.conf rename to stiefelsystem/platform/files/dracut_nbd/etc/dracut.conf.d/60-stiefelsystem.conf diff --git a/stiefelsystem/platform/files/initramfs-tools_nbd/etc/initramfs-tools/scripts/init-premount/stiefelsystem b/stiefelsystem/platform/files/initramfs-tools_nbd/etc/initramfs-tools/scripts/init-premount/stiefelsystem new file mode 100755 index 0000000..54c2033 --- /dev/null +++ b/stiefelsystem/platform/files/initramfs-tools_nbd/etc/initramfs-tools/scripts/init-premount/stiefelsystem @@ -0,0 +1,84 @@ +#!/bin/sh + +PREREQ="" +prereqs() { + echo "$PREREQ" +} + +case $1 in +prereqs) + prereqs + exit 0 + ;; +esac + +# function identical to mkinitcpio_nbd +# don't forget to synchronize changes... +run_hook() { + echo 'waiting for stiefelsystem ethernet device...' + + pushd /sys/class/net/ + local i=0 + while [ ! -d stiefellink ]; do + local renamed=n + for iface in "*"; do + if [ `cat "$iface/address"` == "${stiefel_link}" ]; then + ip link set name stiefellink dev "$iface" && { renamed=y; break; } + fi + done + i=$(($i+1)) + [ $i -eq 200 ] && { + msg "could not find interface ${stiefel_link} :(" + return 1 + } + [ renamed == 'n' ] && sleep 0.1 + done + popd + + ip link set up stiefellink + + echo "waiting for stiefel-server to be reachable..." + i=0 + while true; do + if ping -w1 -c1 ${stiefel_nbdhost}; then + break + else + sleep 0.1 + fi + i=$(($i+1)) + [ $i -eq 200 ] && { + msg "could not ping ${stiefel_nbdhost} :(" + return 1 + } + done + + modprobe nbd + msg "mapping stiefelsystem nbd..." + cmd=nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname} + msg "$cmd" + $cmd || return 1 + msg "stiefelsystem nbd devices mapped." + blkid +} + +case "$(cat /proc/cmdline)" in + *stiefel_link*) + echo "we are beeing stiefeled" + + for x in $(cat /proc/cmdline); do + case "$x" in + stiefel_link=*) + stiefel_link="${x#stiefel_link=}" + ;; + stiefel_nbdhost=*) + stiefel_nbdhost="${x#stiefel_nbdhost=}" + ;; + stiefel_nbdname=*) + stiefel_nbdname="${x#stiefel_nbdname=}" + ;; + esac + done + + run_hook + ;; +esac diff --git a/stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/hooks/nbd b/stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/hooks/nbd new file mode 100644 index 0000000..ec2ed48 --- /dev/null +++ b/stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/hooks/nbd @@ -0,0 +1,48 @@ +#!/bin/bash + +run_hook() { + echo 'waiting for stiefelsystem ethernet device...' + + pushd /sys/class/net/ + local i=0 + while [ ! -d stiefellink ]; do + local renamed=n + for iface in "*"; do + if [ `cat "$iface/address"` == "${stiefel_link}" ]; then + ip link set name stiefellink dev "$iface" && { renamed=y; break; } + fi + done + i=$(($i+1)) + [ $i -eq 200 ] && { + msg "could not find interface ${stiefel_link} :(" + return 1 + } + [ renamed == 'n' ] && sleep 0.1 + done + popd + + ip link set up stiefellink + + echo "waiting for stiefel-server to be reachable..." + i=0 + while true; do + if ping -w1 -c1 ${stiefel_nbdhost}; then + break + else + sleep 0.1 + fi + i=$(($i+1)) + [ $i -eq 200 ] && { + msg "could not ping ${stiefel_nbdhost} :(" + return 1 + } + done + + modprobe nbd + msg "mapping stiefelsystem nbd..." + cmd=nbd-client ${stiefel_nbdhost} /dev/nbd0 -systemd-mark -persist -name ${stiefel_nbdname} + msg "$cmd" + $cmd || return 1 + msg "stiefelsystem nbd devices mapped." + blkid +} diff --git a/overlays/server-os-arch-nbd/etc/initcpio/install/nbd b/stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/install/nbd similarity index 99% rename from overlays/server-os-arch-nbd/etc/initcpio/install/nbd rename to stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/install/nbd index 3e15e24..b209515 100644 --- a/overlays/server-os-arch-nbd/etc/initcpio/install/nbd +++ b/stiefelsystem/platform/files/mkinitcpio_nbd/etc/initcpio/install/nbd @@ -1,5 +1,4 @@ - -#!/bin/bash +#!/bin/sh build() { add_module nbd diff --git a/overlays/server-os-generic/etc/NetworkManager/conf.d/stiefelsystem.conf b/stiefelsystem/platform/files/networkmanager/etc/NetworkManager/conf.d/stiefelsystem.conf similarity index 100% rename from overlays/server-os-generic/etc/NetworkManager/conf.d/stiefelsystem.conf rename to stiefelsystem/platform/files/networkmanager/etc/NetworkManager/conf.d/stiefelsystem.conf diff --git a/overlays/initrd/etc/systemd/system/stiefel-client.service b/stiefelsystem/platform/files/stiefelsystem/etc/systemd/system/stiefel-client.service similarity index 100% rename from overlays/initrd/etc/systemd/system/stiefel-client.service rename to stiefelsystem/platform/files/stiefelsystem/etc/systemd/system/stiefel-client.service diff --git a/overlays/initrd/etc/systemd/system/stiefel-server.service b/stiefelsystem/platform/files/stiefelsystem/etc/systemd/system/stiefel-server.service similarity index 100% rename from overlays/initrd/etc/systemd/system/stiefel-server.service rename to stiefelsystem/platform/files/stiefelsystem/etc/systemd/system/stiefel-server.service diff --git a/stiefelsystem/platform/initramfs_tools.py b/stiefelsystem/platform/initramfs_tools.py new file mode 100644 index 0000000..99ca0f5 --- /dev/null +++ b/stiefelsystem/platform/initramfs_tools.py @@ -0,0 +1,13 @@ +""" +how to configure initramfs-tools from debian/ubuntu to build a +stiefelsystem bootable initrd. +""" + +from . import install_platform_files + + +def install(prefix, cfg): + if 'nbd' in cfg.modules: + install_platform_files('initramfs-tools_nbd') + # re-build the initramfs + command('update-initramfs', '-u', '-k', 'all') diff --git a/stiefelsystem/platform/nbd.py b/stiefelsystem/platform/nbd.py new file mode 100644 index 0000000..fcc9d35 --- /dev/null +++ b/stiefelsystem/platform/nbd.py @@ -0,0 +1,39 @@ +""" +NBD mapping setup +""" + +from .util import install_platform_files +from ..util import FileEditor, command + + +def install(prefix, cfg): + if 'dracut' in cfg.modules: + # config files to include nbd support + install_platform_files("dracut_nbd") + + if "system-arch" in cfg.modules: + if 'dracut' in cfg.modules: + raise NotImplementedError("not yet verified") + install_platform_files('arch_dracut') + + else: + install_platform_files('mkinitcpio_nbd') + + +def configure(): + raise NotImplementedError("not yet verified to work") + + edit = FileEditor('/etc/mkinitcpio.conf') + edit.load_from('/etc/mkinitcpio.conf') + edit.edit_bash_list('BINARIES', {'`which ifrename`': 'at-end'}) + edit.edit_bash_list('MODULES', {'amdgpu': 'at-end', 'i915': 'at-end'}) + + changes = {'autodetect': 'remove'} + if 'nbd' in cfg.modules: + changes['nbd'] = 'before-fsck' + edit.edit_bash_list('HOOKS', changes) + # TODO: allow bypassing the consent question + edit.write() + + # post-hook: re-generate initramfs + #command('mkinitcpio', '-p', 'linux') diff --git a/stiefelsystem/platform/networkmanager.py b/stiefelsystem/platform/networkmanager.py new file mode 100644 index 0000000..fc1ead6 --- /dev/null +++ b/stiefelsystem/platform/networkmanager.py @@ -0,0 +1,9 @@ +""" +NetworkManager configuration files +""" + +from . import install_platform_files + + +def install(prefix, cfg): + install_platform_files("networkmanager") diff --git a/stiefelsystem/platform/stiefelos.py b/stiefelsystem/platform/stiefelos.py new file mode 100644 index 0000000..cf55756 --- /dev/null +++ b/stiefelsystem/platform/stiefelos.py @@ -0,0 +1,56 @@ +""" +what to do to allow starting stiefelos on a hostos. +""" + +import json +from pathlib import Path + +from stiefelsystem.util import FileEditor + + +def install(prefix, cfg): + """ + install files so a host system can find stiefelos files and parameters + to start it. + """ + + # TODO default paths + # on arch: + # "kernel": "vmlinuz-linux", + # "initrd": "initramfs-stiefel.img", + + # tell stiefel-server which kernel to serve to a stiefel-client, + # relative to /boot + boot_config = { + # paths to files inside the boot partition + "kernel": cfg.boot.kernel, + "initrd": cfg.boot.initrd, + # cmdline to pass to the above kernel when its started + "cmdline": cfg.boot.cmdline, + "stiefelmodules": list(cfg.modules.keys()), + } + + # TODO: that's the config file read by stiefel-server what to serve! + edit = FileEditor(prefix / 'boot/stiefelsystem.json') + edit.set_data(json.dumps(boot_config, indent=4).encode() + b'\n') + edit.write() + + # store the stiefelsystem kernel and initrd on the host system + # it will be used for autokexec on the server + stiefelos_initrd_src = cfg.path.work / 'initrd.cpio' + # TODO asdf: the kernel is inside this initrd too... + stiefelos_kernel_src = cfg.path.initrd / 'vmlinuz' + + if not stiefelos_initrd_src.resolve().is_file(): + raise Exception("stiefelos initrd not found - did you create it?") + if not stiefelos_kernel_src.resolve().is_file(): + raise Exception("stiefelos kernel not found - did you create it?") + + edit = FileEditor(prefix / Path(cfg.server_setup.stiefel_os_initrd).relative_to("/")) + edit.load_from(stiefelos_initrd_src) + edit.write() + + # same for the debian-generic-kernel + edit = FileEditor(prefix / Path(cfg.server_setup.stiefel_os_kernel).relative_to("/")) + edit.load_from(stiefelos_kernel_src) + edit.write() diff --git a/stiefelsystem/platform/stiefelsystem.py b/stiefelsystem/platform/stiefelsystem.py new file mode 100644 index 0000000..a80028f --- /dev/null +++ b/stiefelsystem/platform/stiefelsystem.py @@ -0,0 +1,51 @@ +""" +components for stiefelsystem itself. +""" + +import sysconfig +import shutil + +from .util import get_platform_module_files_path, install_platform_files + + +def install(prefix): + """ + install stiefelsystem python module, and service files. + stiefel-server and stiefel-client can then run in the given prefix. + usually this is the stiefelos initrd. + """ + + # install python modules to the initrd + initrd_py_modules = ['stiefelsystem', 'stiefelsystem.platform'] + + # the files are shipped due to setup.py package_data! + # install all platform-specific files so stiefelsystem + # can self-replicate to take over the world, if desired. + module_data_dir_globs = { + 'stiefelsystem.platform': ["files/**/*"] + } + + for module in initrd_py_modules: + mod_dir = get_platform_module_files_path(module) + + # destination directory for the python module + # for a posix system (such as our stiefelOS initrd system) + initrd_mod_dir = (prefix / + sysconfig.get_path("purelib", "posix_prefix")[1:] / + module.replace('.', '/')) + initrd_mod_dir.mkdir(parents=True, exist_ok=True) + + # python module files + for pyfile in mod_dir.iterdir(): + if not pyfile.is_file(): + continue + shutil.copy(pyfile, initrd_mod_dir) + + # install all data files, restricted by glob. + for glob_pattern in module_data_dir_globs.get(module, []): + install_platform_files(glob_pattern, prefix=initrd_mod_dir, + module_name=module, subdirectory=None) + + # systemd units + install_platform_files("stiefeltools") + install_platform_files("networkmanager") diff --git a/stiefelsystem/platform/systemd.py b/stiefelsystem/platform/systemd.py new file mode 100644 index 0000000..e69de29 diff --git a/stiefelsystem/platform/util.py b/stiefelsystem/platform/util.py new file mode 100644 index 0000000..21880fc --- /dev/null +++ b/stiefelsystem/platform/util.py @@ -0,0 +1,56 @@ +""" +utilities for handling platform specific files +""" + +import shutil +import os +import importlib.resources + +from pathlib import Path + + +def get_platform_module_files_path(module_name): + """ + get the directory path where the given python module name + stores its files. + """ + base_module_name = __name__.split(".")[0] + if base_module_name != 'stiefelsystem': + raise Exception( + "stiefelsystem needs to find itself as python module. " + f"sadly, stiefelsystem is currently running as {base_module_name!r}. " + "we need to know this name because we install that python " + "module into the stiefelsystem initrd." + ) + + return Path(importlib.resources.files(module_name)) + + +def install_platform_files(file_glob, destination: Path, + module_name="stiefelsystem.platform", + subdirectory="files"): + """ + recursively install $module_name/$subdirectory/$file_glob into + destination/$file_glob. by default this is platform/files/$glob + + for the default platform file installation, just set file_glob, + e.g. to `initrd`. + """ + + mod_dir = get_platform_module_files_path(module_name) + + if subdirectory: + glob_pattern = os.path.join(subdirectory, file_glob) + base_path = mod_dir / subdirectory + else: + glob_pattern = file_glob + base_path = mod_dir + + for data_path in mod_dir.glob(glob_pattern): + data_relpath = data_path.relative_to(base_path) + if data_path.is_dir(): + shutil.copytree(data_path, destination / data_relpath) + elif data_path.is_file(): + shutil.copy(data_path, destination / data_relpath) + else: + raise Exception(f"platform/files glob result neigher file nor directory: {data_path}") diff --git a/stiefelsystem/qemu.py b/stiefelsystem/qemu.py new file mode 100644 index 0000000..3096d58 --- /dev/null +++ b/stiefelsystem/qemu.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +""" +script to test the server and client in qemu. + +run this in two terminals. + +both machines will use the stiefelsystem's kernel and initrd to boot, +and the server will serve your systems' kernel and initrd to then stiefel the client. + +first you need to start the server: + + stiefelctl test-qemu server + +then, run + + stiefelctl test-qemu client + +they should be able to talk to each other. +the client should download the kernel and initrd from the server, +and proceed up to `kexec`. +""" + +import argparse +import base64 +import contextlib +import json +import os +import tempfile + +from .subcommand import Subcommand, ConfigDefault +from .util import ( + command, + ensure_root, + install_binary, + mac_to_v6ll, +) + + +class QemuTest(Subcommand): + """ + Create the stiefel-client and stiefel-server initramfs image. + It contains all tools for running a stiefel-client or server. + """ + + @classmethod + def register(cls, cli): + cli.add_argument('--kernel', + default=ConfigDefault("boot.kernel"), + help=('the kernel to be provided to a stiefel-client. ' + 'default: use the debian kernel from stiefel\'s initrd')) + cli.add_argument('--initrd', + default=ConfigDefault("boot.initrd"), + help=('the initrd to be delivered to a stiefel-client. ' + 'default: use the debian initrd from stiefel\'s initrd')) + cli.add_argument('--srv-kernel', + default=ConfigDefault("path.initrd", lambda cfg: f'{cfg}/vmlinuz'), + help=('the kernel to booted for this stiefel-server. ' + 'default: use debian kernel from stiefel-initrd')) + cli.add_argument('--srv-initrd', + default=ConfigDefault("path.cpio"), + help='the initrd to loaded to this stiefel-server') + cli.add_argument('mode', choices=['client', 'server']) + cli.add_argument('--cmdline', + help='extra kernel cmdline args for the final system') + cli.add_argument('--forward-usb') + cli.add_argument('--print-client-kexec-invocation', action='store_true', + help=("when set, get a qemu invocation to directly test " + "a stiefel-client kexec run")) + cli.add_argument('--graphical', action='store_true') + cli.add_argument('--serialnetwork', action='store_true', + help="attach ttyS0 to the VM, exposed as telnet tcp port") + cli.add_argument('--skip-confirm', action='store_true') + cli.add_argument('--use-existing-bridge', + help="attach qemu to this existing bridge") + cli.add_argument('--extra-files') + + cli.set_defaults(subcommand=cls) + + def checkfunc(args): + if args.print_client_kexec_invocation and not args.mode == 'server': + cli.error("the client kexec invocation display only works in server mode") + cli.set_defaults(checkfunc=checkfunc) + + + def run(self, args, cfg): + """ + create an initrd suitable for running stiefel-server or stiefel-client + """ + + ensure_root() + + with contextlib.ExitStack() as exit_stack: + tmpdir = exit_stack.enter_context(tempfile.TemporaryDirectory()) + + def tmppath(name): + return os.path.join(tmpdir, name) + + # inner_cmdline is passed to the + # stiefeled real system kernel by stiefel-client. + # they are used in addition to the options served by + # stiefel-server from /boot/stiefelsystem.json + inner_cmdline = "vga=795" + if not args.graphical: + inner_cmdline += " console=ttyS0" + + client_mac = "52:54:00:5f:70:02" + + bootpart_luks = None + bootpart_luks_uuid = None + + if args.mode == 'client': + # client mode only works when a server is already running + + # check if our test might reboot the actual device... + autokexec_active = command("systemctl", "is-active", "stiefel-autokexec.service", + get_retval=True) + if autokexec_active == 0: + print("stiefel-autokexec.service is active on your computer") + print("this test would thus reboot your device during testing!") + print("-> please run:\nsystemctl stop stiefel-autokexec.service") + exit(1) + + mac = client_mac + + inner_cmdline_b64 = base64.b64encode(inner_cmdline.encode('utf-8')).decode('ascii') + + cmdline = [ + "systemd.unit=stiefel-client.service", + "nomodeset", + "stiefel_innercmdline=" + inner_cmdline_b64, + ] + qemu_args = ['-gdb', 'tcp::1234'] + + # qemu boots this kernel + kernel = args.srv_kernel + initrd = args.srv_initrd + + + elif args.mode == 'server': + mac = "52:54:00:5f:70:03" + + with contextlib.ExitStack() as setup_exit_stack: + + # 4 GiB sparse image + disk_size = 4 * 1024 ** 3 + + # create the disk image that will be served to the server + with open(tmppath('loop_file'), 'wb') as loop_file: + loop_file.truncate(disk_size) + + # create partitions in loop file + + if 'lvm' in cfg.modules: + # one gpt partition for the whole disk + command('sgdisk', '-n', '0:0:0', loop_file.name) + else: + # gpt boot and root partition + # 1G boot, the rest for root (like lvm below) + command( + 'sgdisk', '-n', '0:0:+1G', '-n', '0:0:0', loop_file.name + ) + + # create the loop device + loop_device_name = command( + 'losetup', '-fP', '--show', loop_file.name, + capture_stdout=True + ).decode().strip() + + setup_exit_stack.callback( + lambda: command('losetup', '-d', loop_device_name)) + + bootpartition_name = loop_device_name + 'p1' + rootpartition_name = loop_device_name + 'p2' + + if cfg.boot.luks_block is not None: + luks_password = "sft.lol" + command('echo', 'The LUKS password is: ', luks_password) + command('cryptsetup', 'luksFormat', bootpartition_name, '-', + stdin=f'{luks_password}') + + luks_mapped_name = 'stiefel-qemu-root' + command('cryptsetup', 'open', '--type=luks', '--key-file=-', + bootpartition_name, luks_mapped_name, + stdin=f'{luks_password}') + setup_exit_stack.callback( + lambda: command('cryptsetup', 'close', luks_mapped_name)) + + bootpart_luks = bootpartition_name + bootpartition_name = f'/dev/mapper/{luks_mapped_name}' + + bootpart_luks_uuid = command( + 'blkid', + '--output', 'value', + '--match-tag', 'UUID', + bootpart_luks, + capture_stdout=True + ).decode().strip() + + if 'lvm' in cfg.modules: + command('pvcreate', bootpartition_name) + pv_device = bootpartition_name + setup_exit_stack.callback( + lambda: command('pvremove', pv_device)) + + vg_name = 'qemustiefel' + command('vgcreate', vg_name, bootpartition_name) + # use extreme caution here: this removes a vg! + setup_exit_stack.callback( + lambda: command('vgremove', '-y', vg_name)) + + setup_exit_stack.callback( + lambda: command('cp', '--sparse=always', + loop_file.name, tmppath('disk_image'))) + + setup_exit_stack.callback( + lambda: command('vgchange', '--activate=n', vg_name)) + + # like the gpt partitions above + command('lvcreate', '-n', 'boot', '-L', '1G', vg_name) + command('lvcreate', '-n', 'root', '-L', '2G', vg_name) + + rootpartition_name = '/dev/mapper/qemustiefel-root' + bootpartition_name = '/dev/mapper/qemustiefel-boot' + + else: + setup_exit_stack.callback( + lambda: command('mv', loop_file.name, tmppath('disk_image'))) + + # create filesystem on the partition + command('mkfs.vfat', '-F', '16', bootpartition_name) + command('mkfs.ext4', rootpartition_name) + + # mount boot filesystem, fill it with files, and unmount it + os.makedirs(tmppath('mntboot'), exist_ok=True) + command('mount', bootpartition_name, tmppath('mntboot')) + setup_exit_stack.callback( + lambda: command('umount', tmppath('mntboot'))) + + # mount root filesystem, so we can simulate a root-switch + os.makedirs(tmppath('mntroot'), exist_ok=True) + command('mount', rootpartition_name, tmppath('mntroot')) + setup_exit_stack.callback( + lambda: command('umount', tmppath('mntroot'))) + + # this is the to-be-stiefeled kernel and initrd, + # which will be provided to the stiefel-client. + command('cp', args.kernel, tmppath('mntboot') + '/kernel') + command('cp', args.initrd, tmppath('mntboot') + '/initrd') + + with open(tmppath('mntboot') + '/stiefelsystem.json', 'w') as fileobj: + # cmdline options served by stiefel-server + # to the booting client + stiefel_cmdline = [ + "verbose", + ] + + if 'lvm' in cfg.modules: + client_root = "root=/dev/mapper/qemustiefel-root" + else: + client_root = "root=/dev/sda2" + stiefel_cmdline.append(client_root) + + # TODO: actually should be 'initrd-dracut' + if 'system-gentoo' in cfg.modules or 'system-arch-dracut' in cfg.modules: + stiefel_cmdline.extend([ + "rd.info", + "rd.shell", + "rd.retry=15", + ]) + if bootpart_luks_uuid: + stiefel_cmdline.append(f"rd.luks.uuid={bootpart_luks_uuid}") + elif bootpart_luks_uuid: + raise NotImplementedError("luks unlocking not implemented for non-dracut initrd") + + if args.cmdline: + stiefel_cmdline.append(args.cmdline) + + json.dump({ + "kernel": "kernel", + "initrd": "initrd", + "cmdline": stiefel_cmdline, + "stiefelmodules": list(cfg.modules.keys()), + }, fileobj) + + # dummy root filesystem + command('mkdir', *[f"{tmppath('mntroot')}/{dirname}" for dirname in + ('etc', 'sbin', 'bin', 'sys', 'dev', 'proc', 'run', 'tmp', 'lib')]) + command('tee', tmppath('mntroot') + '/etc/os-release', + stdin='NAME=Stiefelsystem\nID=sftstiefel\nPRETTY_NAME="SFT Stiefelsystem"\n') + command('tee', tmppath('mntroot') + '/etc/fstab', + stdin='# lol nope\n') + + install_binary(tmppath('mntroot'), '/bin/sh') + command('tee', tmppath('mntroot') + '/sbin/init', + stdin=("#!/bin/sh\n" + "echo 'sft technologies is proud to announce:'\n" + "echo 'your system is now booted!'\n" + "echo 'it should now work with your real system.'\n" + "echo 'if not, sft technologies is sorry for you.'\n" + "exec /bin/sh\n" + "")) + command('chmod', '+x', tmppath('mntroot') + '/sbin/init') + + if not args.use_existing_bridge: + # setup the bridge that allows connection to the client VM + command('ip', 'link', 'add', 'name', 'br0', 'type', 'bridge') + command('ip', 'link', 'set', 'dev', 'br0', 'up') + command('ip', 'a', 'a', '10.4.5.1/24', 'dev', 'br0') + exit_stack.callback(lambda: command('ip', 'link', 'delete', 'br0')) + + # because inside qemu the loop-device is called sda + if bootpartition_name == loop_device_name + 'p1': + bootpartition_name = "/dev/sda1" + + if bootpart_luks == loop_device_name + 'p1': + bootpart_luks = "/dev/sda1" + + cmdline = [ + "stiefel_bootdisk=/dev/sda", + f"stiefel_bootpart={bootpartition_name}", + ] + cmdline.append("systemd.unit=stiefel-server.service") + + if cfg.boot.luks_block is None: + # TODO: stiefel-server requires a password entry from cryptsetup + # thus we need to launch it manually... + pass + + else: + cmdline.append( + f'stiefel_bootpart_luks={bootpart_luks}' + ) + + qemu_args = [ + "-drive", f"file={tmppath('disk_image')},format=raw,if=none,id=systemhdd", + "-device", "virtio-scsi-pci,id=scsi0", + "-device", "scsi-hd,drive=systemhdd,bus=scsi0.0", + ] + + # qemu boots this kernel + kernel = args.srv_kernel + initrd = args.srv_initrd + + elif args.mode == 'test': + mac = "52:54:00:5f:70:04" + + # network link makes sense to have even in the test vm + command('ip', 'link', 'add', 'name', 'br0', 'type', 'bridge') + command('ip', 'link', 'set', 'dev', 'br0', 'up') + command('ip', 'a', 'a', '10.4.5.1/24', 'dev', 'br0') + exit_stack.callback(lambda: command('ip', 'link', 'delete', 'br0')) + + cmdline = [] + qemu_args = [] + + else: + cli.error("mode must be 'client', 'server' or 'test'") + + with open(tmppath('ifup-script'), 'w') as ifup_script: + ifup_script.writelines([ + "#!/bin/sh\n", + "ip l set $1 up\n", + f"ip l set dev $1 master {args.use_existing_bridge or 'br0'}\n", + f"ip l set {args.use_existing_bridge or 'br0'} up\n", + ]) + os.chmod(tmppath('ifup-script'), 0o755) + + if args.forward_usb is not None: + try: + vid, did = args.forward_usb.split(':') + if len(vid) != 4 or len(did) != 4: + raise ValueError() + except ValueError: + cli.error("--forward-usb expects VID:DID") + + qemu_args.extend([ + "-usb", + "-device", f"usb-host,vendorid=0x{vid},productid=0x{did}" + ]) + + if args.extra_files is not None: + # pack the extra files into an CPIO archive + extra_cpio = command( + "find . -depth -print0 | bsdcpio -0 -o -H newc | gzip -1", + shell=True, + cwd=args.extra_files, + capture_stdout=True, + env={"LANG": "C"}, + ) + with open(args.initrd, 'rb') as initrd_rfile: + args.initrd = tmppath('initrd-concatenated') + with open(args.initrd, 'wb') as initrd_wfile: + initrd_wfile.write(initrd_rfile.read()) + initrd_wfile.write(extra_cpio) + + if args.serialnetwork: + qemu_args.extend([ + "-serial", + "telnet:localhost:4321,server,wait" + ]) + + if not args.graphical: + cmdline.insert(0, "console=ttyS0") + qemu_args.append("-nographic") + confirm = not (args.serialnetwork or args.skip_confirm) + + print("remember: quit qemu with C-a x") + else: + qemu_args.extend([ + "-vga", "virtio", + ]) + cmdline.append("vga=795") + confirm = False + + qemu_base = [ + "qemu-system-x86_64", + "-machine", "q35,accel=kvm", + "-cpu", "host", + "-m", str(3 * 1024 if args.mode == "server" else 5 * 1024), + ] + qemu_kernel = [ + "-kernel", args.srv_kernel, + "-initrd", args.srv_initrd, + ] + qemu_netdev = [ + "-netdev", f"tap,id=network0,script={tmppath('ifup-script')},downscript=no", + ] + qemu_devices = [ + "-device", f"virtio-net,netdev=network0,mac={mac}", + ] + qemu_cmdline = [ + "-append", " ".join(cmdline), + ] + + # generate what the stiefel-client would kexec, + # so one can bypass the stiefelsystem in its entirety + # this is mostly useful for debugging the "real" initrd: + # it starts the machine just like after stiefelsystem + # kexec'd your real kernel+initramfs. + if args.print_client_kexec_invocation: + print("=" * 80) + print("invocation for qemu to directly execute the " + "to-be-stiefeled client+initramfs:") + print() + + # "simulate" the cmdline transfer from stiefel-server + inner_cmdline += " ".join(stiefel_cmdline) + + # we should directly get these cmdline options from stiefel-client code! + # instead we have to copy it :( + if any(mod in ('system-gentoo', 'system-arch-dracut') for mod in cfg.modules): + inner_cmdline += ( + " ifname=stiefellink:" + client_mac + + " ip=stiefellink:link6" + + " netroot=nbd:[" + mac_to_v6ll(mac) + "%stiefellink]:stiefelblock:::-persist" + ) + + if bootpart_luks_uuid: + inner_cmdline += f" rd.luks.uuid={bootpart_luks_uuid}" + + client_kexec = qemu_base + [ + # handy for debugging, but not necessary + "-gdb tcp::1234 -serial telnet:localhost:4321,server,wait -nographic" + ] + [ + "-kernel", args.kernel, + "-initrd", args.initrd, + ] + qemu_netdev + [ + "-device", f"virtio-net,netdev=network0,mac={client_mac}", + ] + ["-append", f"'{inner_cmdline}'"] + + print(" ".join(client_kexec)) + + input("[enter] to continue") + + qemu_launch = (qemu_base + qemu_kernel + qemu_netdev + + qemu_devices + qemu_cmdline + qemu_args) + + # qemu will somehow disable linewrap + exit_stack.callback(lambda: command('setterm', '-linewrap', 'on')) + + command( + *qemu_launch, + confirm=confirm, + ) diff --git a/stiefelsystem/server.py b/stiefelsystem/server.py new file mode 100644 index 0000000..9e21091 --- /dev/null +++ b/stiefelsystem/server.py @@ -0,0 +1,413 @@ +""" +stiefelsystem server code. +this tool waits for connections from stiefel-client. +""" + +import aiohttp.web +import base64 +import hashlib +import io +import json +import multiprocessing +import os +import re +import shutil +import socket +import subprocess +import tarfile +import time +import argparse +import configparser + +from .crypto import encrypt, decrypt +from .util import ensure_root +from .subcommand import Subcommand + + +""" +syntax for config file + +[stiefel] +bootdisk = "/dev/disk/by-id/SOME-ID_HERE" +files-path = "./files" + +the directory of files-path must contain + - stiefelsystem.json (normally found in /boot) + - aes-key + - kernel (as named in stiefelsystem.json) + - initramfs (as named in stiefelsystem.json) + +-> setup: +- copy stiefelsystem.json, kernel, initramfs, aes-key files to ./files +- run it +""" + + +class Server(Subcommand): + """ + Run stiefelsystem in server mode. + """ + + @classmethod + def register(cls, cli): + cli.add_argument("-c", "--config", + help="path to config file") + cli.add_argument("--no-nbd", action="store_true", + help="do not generate a config file for nbd-server") + cli.add_argument("--add-cmd", + help="arguments to add to the served cmdline") + cli.set_defaults(subcommand=cls) + + + def run(self, args, cfg): + """ + create an initrd suitable for running stiefel-server or stiefel-client + """ + + ensure_root() + + if args.config: + config = configparser.ConfigParser() + config.read(args.config) + + cmdlineargs = { + "stiefel_bootdisk": json.loads(config.get("stiefel", "bootdisk")), + "stiefel_bootpart": "" + } + files_path = json.loads(config.get("stiefel", "files-path")) + keyfilepath = os.path.join(files_path, "aes-key") + nbd_config_path = os.path.join(files_path, "./nbd-config") + + standalone = True + + else: + # automatically turn the display off to save power + subprocess.check_call(['setterm', '--powerdown', '1', '--blank', '1']) + + # the boot partition is mounted here + files_path = "/mnt/" + + # read config from kernel cmdline + print(f"reading config from kernel cmdline") + + with open('/proc/cmdline') as cmdlinefile: + cmdline = cmdlinefile.read() + cmdlineargs = {} + for entry in cmdline.strip().split(): + try: + key, value = entry.split('=', maxsplit=1) + cmdlineargs[key] = value + except ValueError: + continue + + keyfilepath = "/aes-key" + nbd_config_path = "/etc/nbd-server/config" + standalone = False + + print(f"config: {cmdlineargs}") + + with open(keyfilepath, "rb") as keyfile: + KEY = keyfile.read() + KEY_HASH = hashlib.sha256(KEY).hexdigest().encode() + + + def discovery_server(): + """ + listens for and responds to discovery multicast messages, + thus providing its IP to interested clients. + + designed to be run in a multiprocessing subprocess. + """ + discovery_port = 61570 # determined by random.choice(range(49152, 2**16)) + nameinfo_flags = socket.NI_NUMERICHOST + + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('', discovery_port)) + # allow multicast loopback for development + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + + print("discovery server: listening for packets") + while True: + data, addr = sock.recvfrom(1024) + if data != b'stiefelsystem:discovery:find-server:' + KEY_HASH: + continue + host, _ = socket.getnameinfo(addr, nameinfo_flags) + print(f"{host!r} is looking for us") + try: + sock.sendto(b'stiefelsystem:discovery:server-hello:' + KEY_HASH, addr) + except BaseException as exc: + print(f"cannot send discovery reply: {exc!r}") + + + def continuous_network_setup(): + """ + continuously enables all network interfaces as they become available. + + designed to be run in a multiprocessing subprocess. + """ + print('running continuous network setup') + while True: + for netdev in os.listdir('/sys/class/net'): + with open(f'/sys/class/net/{netdev}/operstate') as state_file: + if state_file.read().strip() != 'down': + continue + print(f"setting link up: {netdev!r}") + try: + subprocess.check_call(['ip', 'link', 'set', 'up', netdev]) + except BaseException as exc: + print(f"could not set link up: {exc!r}") + + # instead of time.sleep(), we could use udev like in stiefel-autokexec + time.sleep(0.5 * 20) + + + # challenge for clients to prevent replay attacks + CHALLENGE = base64.b64encode(os.urandom(16)).decode('ascii') + + BLKDEV = cmdlineargs["stiefel_bootdisk"] + BOOTPART_LUKS = cmdlineargs.get("stiefel_bootpart_luks") + BOOTPART = cmdlineargs["stiefel_bootpart"] + UNSECURE = bool(int(cmdlineargs.get("stiefel_unsecure", "0"))) + + if not standalone: + multiprocessing.Process(target=continuous_network_setup).start() + multiprocessing.Process(target=discovery_server).start() + + # create NBD server config + nbdconfig = f""" + [generic] + [stiefelblock] + exportname = {BLKDEV} + copyonwrite = false + """ + + if args.no_nbd: + print("NBD config file is not written.\n Content would be:") + print(nbdconfig) + else: + with open(nbd_config_path, "w") as nbdconfigfile: + nbdconfigfile.write(nbdconfig) + + # open NBD server + print(f"opening NBD server for {BLKDEV}") + if standalone: + def standalone_nbd(): + print("starting NBD serve in standalone mode") + subprocess.check_call(['nbd-server', '-C', nbd_config_path]) + multiprocessing.Process(target=standalone_nbd).start() + else: + subprocess.check_call(['systemctl', 'start', 'nbd-server']) + + + def read_binary(filename): + if not os.path.isfile(filename): + return None + with open(filename, 'rb') as fileobj: + return fileobj.read() + + + def find_boot_config(): + print('trying to find boot config') + + # kernel hints were manually created for the stiefelsystem + stiefelsystem_config = read_binary(os.path.join(files_path, "stiefelsystem.json")) + + ret = { + 'kernel': None, + 'initrd': None, + 'cmdline': None, + 'stiefelmodules': None, + } + + if stiefelsystem_config is not None: + stiefelsystem_config_json = json.loads(stiefelsystem_config) + ret['cmdline'] = " ".join(stiefelsystem_config_json['cmdline']).encode('utf-8') + ret['stiefelmodules'] = " ".join(stiefelsystem_config_json['stiefelmodules']).encode('utf-8') + + kerneldef = stiefelsystem_config_json.get('kernel') + while kerneldef.startswith("/"): + kerneldef = kerneldef[1:] + if kerneldef: + ret['kernel'] = os.path.join(files_path, kerneldef).encode('utf-8') + + initrddef = stiefelsystem_config_json.get('initrd') + while initrddef.startswith("/"): + initrddef = initrddef[1:] + if initrddef: + ret['initrd'] = os.path.join(files_path, initrddef).encode('utf-8') + + # we're missing kernel invocation options, try to parse the bootloader config + if not (ret['kernel'] and ret['initrd'] and ret['cmdline']): + # try to determine kernel from syslinux config + syslinux_config = read_binary(os.path.join(files_path, "/syslinux/syslinux.cfg")) + if syslinux_config is not None: + ret['kernel'] = os.path.join( + os.path.join(files_path, "syslinux").encode('utf-8'), + re.search(rb'^\s*LINUX\s+(\S+)\s', syslinux_config, re.M).group(1) + ) + ret['initrd'] = os.path.join( + os.path.join(files_path, "syslinux").encode('utf-8'), + re.search(rb'^\s*INITRD\s+(\S+)\s', syslinux_config, re.M).group(1) + ) + # TODO: cmdline parsing for syslinux cfg + + if not (ret['kernel'] and ret['initrd'] and ret['cmdline']): + # try to determine kernel from grub config + grub_config = read_binary(os.path.join(files_path, "grub/grub.cfg")) + if grub_config is not None: + kernelcmd = re.search(rb'^\s*linux\s+(\S+)((?:\s(?:\S+))*)\s*$', grub_config, re.M) + if not kernelcmd: + raise Exception('could not find kernel invocation in grub cfg') + + initrdcmd = re.search(rb'^\s*initrd\s+(\S+)\s', grub_config, re.M) + if not initrdcmd: + raise Exception('could not find initrd definition in grub cfg') + + ret['kernel'] = os.path.join(files_path, kernelcmd.group(1)) + ret['initrd'] = os.path.join(files_path, initrdcmd.group(1)) + ret['cmdline'] = os.path.join(files_path, kernelcmd.group(2)) + + for k, v in ret.items(): + if not v: + raise Exception(f'no configuration found for {k!r}') + + if args.add_cmd: + ret['cmdline'] += b' ' + args.add_cmd.encode('utf-8') + + return ret + + + async def generate_boot_tar(payload): + print("reading kernel and initrd") + + challenge = payload['challenge'] + + if BOOTPART_LUKS: + # open luks device to fetch kernel and initrd from it + print("opening luks-crypted device...") + decrypt_mapped = 'stiefelboot' + while os.path.exists(f"/dev/mapper/{decrypt_mapped}"): + decrypt_mapped += '_' + + luks_pw_enc = base64.b64decode(payload['lukspw']) + luks_phrase = decrypt(luks_pw_enc) + + subprocess.run(['cryptsetup', 'open', '--type=luks', + '--key-file=-', + BOOTPART_LUKS, decrypt_mapped], + input=luks_phrase, + check=True) + del luks_phrase + + # force-discover the new pvs and lvs + if shutil.which('lvm'): + subprocess.check_call(['pvscan']) + subprocess.check_call(['lvscan']) + subprocess.check_call(['udevadm', 'settle', + f'--exit-if-exists={BOOTPART}']) + + try: + if not standalone: + subprocess.check_call(['mount', '-oro', BOOTPART, files_path]) + bootcfg = find_boot_config() + + print(f"kernel: {bootcfg['kernel'].decode(errors='replace')!r}") + kernelblob = read_binary(bootcfg['kernel']) + print(f"initrd: {bootcfg['initrd'].decode(errors='replace')!r}") + initrdblob = read_binary(bootcfg['initrd']) + + finally: + if not standalone: + subprocess.check_call(['umount', files_path]) + + if BOOTPART_LUKS: + if shutil.which('lvm'): + # deactivate all lvm child blockdevices + blocktree = json.loads(subprocess.check_output( + ['lsblk', '--json', f'/dev/mapper/{decrypt_mapped}']).decode()) + + for block in blocktree['blockdevices'][0]['children']: + if block['type'] == 'lvm': + subprocess.check_call( + ['lvchange', '-an', f"/dev/mapper/{block['name']}"]) + + subprocess.check_call(['cryptsetup', 'close', decrypt_mapped]) + + # create the response TAR in-memory + with io.BytesIO() as fileobj: + with tarfile.open(fileobj=fileobj, mode='w') as tar: + tf = tarfile.TarInfo('kernel') + tf.size = len(kernelblob) + tar.addfile(tf, io.BytesIO(kernelblob)) + + tf = tarfile.TarInfo('initrd') + tf.size = len(initrdblob) + tar.addfile(tf, io.BytesIO(initrdblob)) + + # unique content so a client can detect replay attacks + tf = tarfile.TarInfo('challenge') + challenge = challenge.encode('utf-8') + tf.size = len(challenge) + tar.addfile(tf, io.BytesIO(challenge)) + + cmdline = bootcfg['cmdline'] + tf = tarfile.TarInfo('cmdline') + tf.size = len(cmdline) + tar.addfile(tf, io.BytesIO(cmdline)) + + stiefelmodulelist = bootcfg['stiefelmodules'] + tf = tarfile.TarInfo('stiefelmodules') + tf.size = len(stiefelmodulelist) + tar.addfile(tf, io.BytesIO(stiefelmodulelist)) + + fileobj.seek(0) + return fileobj.read() + + + async def server_infos(request): + return aiohttp.web.json_response({ + "what": "stiefelsystem-server", + "args": cmdlineargs, + "key-hash": KEY_HASH.decode(), + "challenge": CHALLENGE, + "need-luks": bool(BOOTPART_LUKS), + }) + + + async def get_boot_tar_noauth(request): + if UNSECURE: + return aiohttp.web.Response(body=generate_boot_tar({'challenge': ""}), + content_type="application/x-binary") + + return aiohttp.web.Response(body="only boot.tar.aes is available", + status=403) + + + async def get_encrypted_boot_tar(request): + """ + generate a boot tar which includes a random challenge the client + gave us so the archive is fresh and + the client can detect replay attacks of boot archives. + """ + payload = await request.json() + + plaintext = await generate_boot_tar(payload) + + print('encrypting boot.tar.aes') + encrypted_blob = encrypt(plaintext) + print('encryption done') + del plaintext + + return aiohttp.web.Response(body=encrypted_blob, + content_type="application/x-binary") + + + print("running HTTP server") + + srv = aiohttp.web.Application() + srv.add_routes([aiohttp.web.get('/', server_infos)]) + srv.add_routes([aiohttp.web.get('/boot.tar', get_boot_tar_noauth)]) + srv.add_routes([aiohttp.web.post('/boot.tar.aes', get_encrypted_boot_tar)]) + + aiohttp.web.run_app(srv, host="::", port=4644) diff --git a/stiefelsystem/stiefelos/__init__.py b/stiefelsystem/stiefelos/__init__.py new file mode 100644 index 0000000..71e8053 --- /dev/null +++ b/stiefelsystem/stiefelos/__init__.py @@ -0,0 +1,4 @@ +""" +code for creation, configuration and invocation of StiefelOS, +which is a micro Linux distribution to achieve the network transfers. +""" diff --git a/stiefelsystem/stiefelos/creator.py b/stiefelsystem/stiefelos/creator.py new file mode 100644 index 0000000..4ac6751 --- /dev/null +++ b/stiefelsystem/stiefelos/creator.py @@ -0,0 +1,347 @@ +""" +Code for generating an initrd system capable of running as stiefel-server +or stiefel-client. So it's used to serve the disk over network, or chain-load +into the server's OS. +""" + +from pathlib import Path +import os +import shutil + +from ..util import ( + command, + download_tar, + ensure_root, + ensure_single_system, + initrd_write, + list_files_in_packages, + mount_tmpfs, + umount, +) +from ..subcommand import Subcommand, ConfigDefault +from ..platform import stiefelsystem, stiefelos + + +class StiefelOSCreator(Subcommand): + """ + Create the StiefelOS initramfs for running stiefel-client and stiefel-server. + It is a minimal operating system to share disks or boot from them. + """ + + @classmethod + def register(cls, cli): + # TODO: use the system mirror if there is one? + cli.add_argument('--debian-mirror', default='http://mirror.stusta.de/debian/') + cli.add_argument('--out', default=ConfigDefault("path.cpio"), + help=("output cpio file path. " + "default: %(default)s")) + cli.add_argument('--compressor', default=ConfigDefault("packing.compressor"), + help=("compression format to use for initrd. " + "default: %(default)s")) + cli.add_argument('--skip-setup', action='store_true') + cli.add_argument('--update', action='store_true') + cli.add_argument('--tmp-work', action='store_true', + help='use a tmpfs for work and cachedir') + cli.add_argument('--prefix', default="/", + help=("filesystem prefix to prepend to the resulting " + "kernel/initrd installation path")) + cli.set_defaults(subcommand=cls) + + def checkfunc(args): + args.prefix = Path(args.prefix) + cli.set_defaults(checkfunc=checkfunc) + + def run(self, args, cfg): + """ + Create an initrd suitable for running stiefel-server or stiefel-client + """ + + self.create_image(cfg=cfg, prefix=args.prefix, skip_setup=args.skip_setup, + tmp_work=args.tmp_work, debian_mirror_url=args.debian_mirror, + update_packages=args.update, compressor=args.compressor, + output_file=args.out) + + def create_image(self, cfg, prefix, skip_setup, tmp_work, debian_mirror_url, update_packages, + compressor=None, output_file=None): + + ensure_root() + ensure_single_system(cfg) + + if not skip_setup: + # discard existing ramdisk content + umount(cfg.path.initrd_devel) + umount(cfg.path.work) + + os.makedirs(cfg.path.work, exist_ok=True) + os.makedirs(cfg.path.cache, exist_ok=True) + + # provide the various tmpfses + if tmp_work: + mount_tmpfs(cfg.path.work) + mount_tmpfs(cfg.path.cache) + + deb_packages = [ + 'ifrename', # needed by the payload scripts + 'iproute2', # needed by the payload scripts + 'kexec-tools', # needed for booting the payload system + 'linux-image-amd64', # needed for booting the stiefel system + 'python3', # all payload scripts are written in Python3 + 'python3-pycryptodome', + 'python3-aiohttp', + 'systemd-container', # to allow launching inside nspawn during this script + 'systemd-sysv', # to provide symlinks in /sbin: init, poweroff, ... + ] + + if 'nbd' in cfg.modules: + deb_packages.extend(['nbd-server']) + + if 'lvm' in cfg.modules: + deb_packages.append('lvm2') + + if 'i915' in cfg.modules: + deb_packages.append('firmware-misc-nonfree') + + if cfg.boot.luks_block: + deb_packages.append('cryptsetup') + + deb_packages.extend(cfg.initrd.include_packages) + + # perform initial setup + # this logs to $workdir/$workdir_subpath_initrd/debootstrap/debootstrap.log + # -> usually "workdir/initrd.nspawn/debootstrap/debootstrap.log" + print(f"running debootstrap with log {cfg.path.initrd}/debootstrap/debootstrap.log") + command( + 'debootstrap', + '--include=' + ','.join(deb_packages), + '--cache-dir=' + os.path.abspath(cfg.path.cache), + '--variant=minbase', + '--components=main,contrib,non-free', + '--merged-usr', + '--verbose', + 'stable', + cfg.path.initrd, + debian_mirror_url, + ) + + # install the stiefelsystem python module, .service and config files + stiefelsystem.install(Path(cfg.path.initrd)) + + # generate and install AES key securing the initial server/client communication + # TODO: store in /var/lib/stiefelsystem as statedir of config file + if not os.path.exists(cfg.aes_key_location): + print('generating new AES key...') + with open(cfg.aes_key_location, 'wb') as fileobj: + fileobj.write(os.urandom(16)) + + # TODO rename to something stiefel-related + shutil.copy(cfg.aes_key_location, Path(cfg.path.initrd) / 'aes-key') + + if not args.skip_setup: + # set root password + command( + 'chpasswd', + nspawn=cfg.path.initrd, + stdin=f'root:{cfg.initrd.password}\n' + ) + + # set root shell + command( + 'chsh', '-s', + cfg.initrd.shell, + nspawn=cfg.path.initrd + ) + + # enable login from systemd-nspawn + initrd_write( + cfg.path.initrd, + '/etc/securetty', + 'pts/0', + append=True + ) + + # set the hostname + initrd_write( + cfg.path.initrd, + '/etc/hostname', + 'stiefelsystem' + ) + + # disable lidswitch handling... + initrd_write( + cfg.path.initrd, + '/etc/systemd/logind.conf', + 'HandleSuspendKey=ignore', + 'HandleHibernateKey=ignore', + 'HandleLidSwitch=ignore', + 'HandleLidSwitchExternalPower=ignore', + 'HandleLidSwitchDocked=ignore', + append=True + ) + + # systemd configuration + command('systemctl', 'set-default', + 'multi-user.target', + nspawn=cfg.path.initrd + ) + command('systemctl', 'enable', + 'fake-entropy.service', + nspawn=cfg.path.initrd + ) + command('systemctl', 'disable', + 'rsyslog.service', + 'cron.service', + 'networking.service', + 'kexec.service', + 'kexec-load.service', + 'machines.target', + 'remote-fs.target', + nspawn=cfg.path.initrd + ) + command('systemctl', 'mask', + 'serial-getty@.service', + 'apt-daily.timer', + 'apt-daily-upgrade.timer', + 'serial-getty@.service', + 'systemd-journal-flush.service', + 'systemd-timedated.service', + 'systemd-timesyncd.service', + 'systemd-tmpfiles-clean.timer', + 'systemd-update-utmp.service', + 'systemd-update-utmp-runlevel.service', + 'time-sync.target', + nspawn=cfg.path.initrd + ) + + # stiefel configuration + if 'nbd' in cfg.modules: + command('systemctl', 'disable', 'nbd-server.service', nspawn=cfg.path.initrd) + # the nbd server configuration will be generated on-the-fly + + # kernel name + kernel_name = os.readlink(cfg.path.initrd + '/vmlinuz')[13:] + + # create devel overlay system which we'll use to compile a few things + os.makedirs(cfg.path.initrd_devel, exist_ok=True) + os.makedirs(cfg.path.initrd_devel + "-overlayfs-upperdir", exist_ok=True) + os.makedirs(cfg.path.initrd_devel + "-overlayfs-workdir", exist_ok=True) + + command( + "mount", + "-t", "overlay", + "overlay", + "-o", ",".join([ + f"lowerdir={os.path.abspath(cfg.path.initrd)}", + f"upperdir={os.path.abspath(cfg.path.initrd_devel + '-overlayfs-upperdir')}", + f"workdir={os.path.abspath(cfg.path.initrd_devel + '-overlayfs-workdir')}", + ]), + os.path.abspath(cfg.path.initrd_devel), + ) + + command('apt', 'update', nspawn=cfg.path.initrd_devel) + command('apt', 'upgrade', nspawn=cfg.path.initrd_devel) + command('apt', 'install', '-y', + 'build-essential', + 'git', + 'linux-headers-amd64', + 'kmod', + nspawn=cfg.path.initrd_devel + ) + + # compile and install the userland driver for the clevo fan controller + if 'clevo-fancontrol' in cfg.modules: + download_tar( + cfg.mod_config["clevo-fancontrol"].url, + os.path.join(cfg.path.initrd_devel, "root/fancontrol") + ) + command('make', '-C', '/root/fancontrol', nspawn=cfg.path.initrd_devel) + command( + "cp", + cfg.path.initrd_devel + "/root/fancontrol/clevo-fancontrol", + cfg.path.initrd + "/usr/local/bin", + ) + command( + "cp", + cfg.path.initrd_devel + "/root/fancontrol/clevo-fancontrol.service", + cfg.path.initrd + "/etc/systemd/system", + ) + command('systemctl', 'enable', + 'clevo-fancontrol.service', + nspawn=cfg.path.initrd + ) + + if update_packages: + command('apt', 'update', nspawn=cfg.path.initrd_devel) + command('apt', 'upgrade', nspawn=cfg.path.initrd_devel) + + if 'debug' in cfg.modules: + command('updatedb', nspawn=cfg.path.initrd) + + if not skip_setup: + if 'debug' not in cfg.modules: + # strip kernel modules + count = 0 + for path, _, files in os.walk(cfg.path.initrd + '/lib/modules'): + for filename in files: + if filename.endswith('.ko'): + full_path = os.path.join(path, filename) + command('strip', '--strip-debug', full_path, silent=count) + count += 1 + if count > 1: + print(f'$ ... (stripped {count - 1} more modules)') + + # the folder is ready, now we can pack and compress the initrd + + paths_to_exclude = set( + list_files_in_packages(cfg.packing.exclude_packages, cfg.path.initrd) + ) + paths_to_exclude.update( + path.encode() for path in cfg.packing.exclude_paths + ) + + old_cwd = os.getcwd() + os.chdir(cfg.path.initrd) + + def scan_path(path): + for name in os.listdir(path): + full = os.path.normpath(os.path.join(path, name)) + + if full in paths_to_exclude: + continue + if full.endswith(b'__pycache__'): + continue + + yield full + # recurse into directories (don't follow links) + if os.path.isdir(full) and not os.path.islink(full): + yield from scan_path(full) + + print(f'packing {cfg.path.initrd}') + + archive = command( + "bsdcpio", "-0", "-o", "-H", "newc", + stdin=b'\0'.join(scan_path(b'.')) + b'\0', + capture_stdout=True, + env={"LANG": "C"}, + ) + + os.chdir(old_cwd) + del old_cwd + + print(f"uncompressed CPIO: {len(archive)} bytes") + + compressor_cmd = compressor or cfg.packing.compressor + compressed = command( + f"pv -s {len(archive)} | {compressor_cmd}", + shell=True, + stdin=archive, + capture_stdout=True + ) + print(f"compressed CPIO: {len(compressed)} bytes") + + output_filename = output_file or cfg.path.cpio + with open(output_filename, "wb") as cpiofile: + cpiofile.write(compressed) + + # install the stiefelos initrd image + # only possible if it was created (debootstrapped) already. + stiefelos.install(prefix, cfg) diff --git a/stiefelsystem/stiefelos/launch.py b/stiefelsystem/stiefelos/launch.py new file mode 100644 index 0000000..cf701bf --- /dev/null +++ b/stiefelsystem/stiefelos/launch.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 + +""" +Code for generating an initrd system capable of running as stiefel-server +or stiefel-client. So it's used to serve the disk over network, or chain-load +into the server's OS. +""" + +import argparse +import base64 +import hmac +import multiprocessing +import os +import socket + +from ..util import command +from ..subcommand import Subcommand + + +class StiefelOSLauncher(Subcommand): + """ + Wait for a discovery message to launch StiefelOS + """ + + @classmethod + def register(cls, cli): + sp = cli.add_subparsers(dest="launchmode") + sp.required = True + + commonp = argparse.ArgumentParser(add_help=False) + commonp.add_argument('--now', action='store_true', + help="launch StiefelOS now, don't wait for a trigger") + + serverp = sp.add_parser("server", parents=[commonp]) + clientp = sp.add_parser("client", parents=[commonp]) + + cli.set_defaults(subcommand=cls) + + def run(self, args, cfg): + self.cfg = cfg + + if os.path.exists('/sys/class/net/stiefellink'): + print("won't kexec since this is already a stiefeled system. " + "we assume it's stiefeled because of the 'stiefellink' network interface.") + return + + if args.launchmode == "server": + self.launch_server(args) + elif args.launchmode == "client": + self.launch_client(args) + else: + raise Exception(f"invalid launchmode {args.launchmode}") + + def launch_client(args): + if args.now: + raise NotImplementedError("launching stiefelOS client via kexec not yet supported") + # self.do_kexec_client() + else: + raise Exception("no client launch triggers available, use --now.") + + def launch_server(args): + if args.now: + self.do_kexec_server() + return + + if not self.cfg.autokexec.macs and not self.cfg.autokexec.broadcast: + print("no trigger-based launch method enabled. maybe use --now?") + return + + with open(self.cfg.aes_key_location, 'rb') as keyfileobj: + aeskey = keyfileobj.read() + + if self.cfg.autokexec.broadcast: + aes_key_hash = hashlib.sha256(aeskey).hexdigest().encode() + hmac_key = hashlib.sha256(b'autokexec-reboot/' + aeskey).hexdigest().encode() + multiprocessing.Process( + target=self.kexec_on_server_discovery_message, + args=(aes_key_hash, hmac_key) + ).start() + + if self.cfg.autokexec.macs: + self.kexec_on_adapter_found(self.cfg.autokexec.macs) + + def kexec_on_adapter_found(self, adapters): + print('waiting for one of these adapters:') + for mac in adapters: + print(f' {mac}') + + import pyudev + context = pyudev.Context() + udev_monitor = pyudev.Monitor.from_netlink(context) + udev_monitor.filter_by(subsystem='net') + while True: + # test if we have the adapter now + for netif in os.listdir('/sys/class/net'): + with open(f'/sys/class/net/{netif}/address') as addrfile: + mac = addrfile.read().strip() + if mac in adapters: + print(f"adapter found: {mac}") + self.do_kexec_server() + + # wait until something happens + udev_monitor.poll() + + def kexec_on_server_discovery_message(self, aes_key_hash, hmac_key): + discovery_port = 61570 # determined by random.choice(range(49152, 2**16)) + nameinfo_flags = socket.NI_NUMERICHOST + + challenge = base64.b64encode(os.urandom(16)) + + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('', discovery_port)) + # allow multicast loopback for development + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + + print("kexec-on-server-discovery-message: listening for messages") + while True: + data, addr = sock.recvfrom(1024) + if data == b'stiefelsystem:discovery:find-server:' + aes_key_hash: + host, _ = socket.getnameinfo(addr, nameinfo_flags) + print(f"{host!r} is looking for a stiefelsystem server! challenging it...") + try: + sock.sendto( + b'stiefelsystem:discovery:autokexec-hello:' + aes_key_hash + + b':' + challenge, addr + ) + except BaseException as exc: + print(f"cannot send discovery reply: {exc!r}") + elif data.startswith(b'stiefelsystem:discovery:autokexec-reboot:' + aes_key_hash + b':'): + response = data.split(b':')[-1] + if hmac.compare_digest( + response, + hmac.new(hmac_key, challenge, digestmod='sha256').hexdigest().encode() + ): + # this reboot request is authentic, it solved our challenge + self.do_kexec_server() + else: + print(f'bad HMAC signature for autokexec-reboot challenge') + + def do_kexec_server(self): + print(f'booting into stiefelsystem server') + + cmdline = [ + f'systemd.unit=stiefel-server.service', + f'stiefel_bootdisk={self.cfg.boot.disk}', + f'stiefel_bootpart={self.cfg.boot.part}', + ] + + bootpart_luks = self.cfg.boot.luks_block + if bootpart_luks is not None: + cmdline.append( + f'stiefel_bootpart_luks={bootpart_luks}' + ) + + cmdline.extend(config.get("cmdline", [])) + + command( + 'kexec', + self.cfg.server_setup.stiefel_os_kernel, + '--ramdisk=' + self.cfg.server_setup.stiefel_os_initrd, + '--reset-vga', + '--console-vga', + '--command-line=' + ' '.join(self.cfg.server_setup.cmdline) + ) diff --git a/stiefelsystem/subcommand.py b/stiefelsystem/subcommand.py new file mode 100644 index 0000000..66cae6f --- /dev/null +++ b/stiefelsystem/subcommand.py @@ -0,0 +1,64 @@ +""" +Stiefelsystem subcommand. +""" + +from .config import Config + +import argparse +import abc + + +class Subcommand(abc.ABC): + """ + Base for a stiefelsystem module. + """ + + def __init__(self): + pass + + @abc.abstractclassmethod + def register(cls, subparser: argparse.ArgumentParser): + """ fill the given subparser with command-specific options """ + raise NotImplementedError() + + @abc.abstractmethod + def run(self, args, cfg): + """ + execute the subcommand, given parsed args and the processed + configuration. return the program's exit code. + """ + raise NotImplementedError() + + +class ConfigDefault: + """ + used as special default argument value. + when not overridden by the cli arg, + we take a configuration entry as value. + """ + def __init__(self, config_key, formatter=None): + self.key = config_key + self.formatter = formatter + + def get_value(self, cfg): + """ + get the stored key from the configuration. + if there was a formatter set, apply it to the configuration value. + """ + cfgkeys = self.key.split(".") + for member in cfgkeys[:-1]: + cfg = getattr(cfg, member) + ret = getattr(cfg, cfgkeys[-1]) + if self.formatter: + ret = self.formatter(ret) + return ret + + def __str__(self): + """ + string representation of this default option, + displayed in the cli help text. + """ + if self.formatter: + return f"config's {self.formatter(self.key)}" + else: + return f"config's {self.key}" diff --git a/stiefelsystem/update.py b/stiefelsystem/update.py new file mode 100644 index 0000000..9c50d26 --- /dev/null +++ b/stiefelsystem/update.py @@ -0,0 +1,69 @@ +""" +automatic update for your current system +""" + +from pathlib import Path + +from .subcommand import Subcommand +from .stiefelos.creator import StiefelOSCreator + + +class Updater(Subcommand): + """ + Discover how your system is set up so stiefelsystem configuration + and installation is adapted. + + TODO: things to do here: + - refresh host os setup (static files) + show diff, should remain identical if installed via distro) + - make sure stiefelos was built properly + - figure out the blocks, luks, ... to boot without configuration + - write json config read by autokexec / stiefel-server + """ + + @classmethod + def register(cls, cli): + cli.add_argument("-p", "--prefix", default="/", + help="filesystem root prefix to operate on") + # get all options from stiefelos creation + StiefelOSCreator.register(cli) + + cli.set_defaults(subcommand=cls) + + def checkfunc(args): + args.prefix = Path(args.prefix) + cli.set_defaults(checkfunc=checkfunc) + + def run(self, args, cfg): + + stiefeloscreator = StiefelOSCreator() + stiefeloscreator.run(args, cfg) + + with open('aes-key', 'rb') as keyfileobj: + KEY = keyfileobj.read() + + # TODO: this only works after stiefelos was generated! + + # write the config.json after everything was created/updated successfully + stiefel_config = { + 'aes-key-hash': hashlib.sha256(KEY).hexdigest(), + 'hmac-key': hashlib.sha256(b'autokexec-reboot/' + KEY).hexdigest(), + "autokexec-triggers": { + "mac_detectoion": cfg.autokexec.macs, + "broadcast": cfg.autokexec.broadcast, + "adapters": cfg.autokexec.macs, + }, + "bootdisk": cfg.boot.disk, + "bootpart-luks": cfg.boot.luks_block, + "bootpart": cfg.boot.part, + "stiefelsystem-kernel": cfg.server_setup.stiefelsystem_kernel, + "stiefelsystem-initrd": cfg.server_setup.stiefelsystem_initrd, + "cmdline": cfg.server_setup.cmdline, + } + + # TODO: rename to autokexec config + edit = FileEditor(args.prefix / 'etc/stiefelsystem/config.json') + edit.set_data(json.dumps(stiefel_config, indent=4).encode() + b'\n') + edit.write() + + raise NotImplementedError() diff --git a/stiefelsystem/usbdrive.py b/stiefelsystem/usbdrive.py new file mode 100644 index 0000000..e9f50ba --- /dev/null +++ b/stiefelsystem/usbdrive.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +script to create the usb flash drive for booting the client +""" +import argparse +import base64 +import os +import tempfile + +from .util import ( + command, + ensure_root, + get_consent, + warn, +) +from .subcommand import Subcommand + + +class USBDriveCreator(Subcommand): + """ + Generate an image for a bootable USB stick and flash it to some a OS so it can operate as a stiefel-server. + """ + + @classmethod + def register(cls, cli): + cli.add_argument('blockdev') + # some older BIOSes only boot if the boot partition starts at sector 32... + cli.add_argument('--first-sector', type=int, default=32) + cli.set_defaults(subcommand=cls) + + def run(self, args, cfg): + """ + create a bootable USB stick for starting stiefel-client. + """ + + if not os.path.exists(args.blockdev): + cli.error(f'block device does not exist: {args.blockdev!r}') + + partition = f'{args.blockdev}1' + ensure_root() + + command('lsblk') + + warn(f'wiping entire drive at {args.blockdev!r} to create {partition!r}') + + if not get_consent(): + raise SystemExit(1) + + # TODO create gpt table + + # create partition table and write MBR + command('sfdisk', args.blockdev, stdin=f"label: dos\n{args.first_sector},1638400,c,*\n") + if any(mod in ('system-arch', 'system-arch-dracut') for mod in cfg.modules): + command('dd', 'if=/usr/lib/syslinux/bios/mbr.bin', 'of=' + args.blockdev) + elif 'system-debian' in cfg.modules: + command('dd', 'if=/usr/lib/syslinux/mbr/mbr.bin', 'of=' + args.blockdev) #debian + elif 'system-gentoo' in cfg.modules: + command('dd', 'if=/usr/share/syslinux/mbr.bin', 'of=' + args.blockdev) + else: + print("no system specified in config.yaml modules") + exit(1) + + # create filesystem + command('mkfs.vfat', '-F', '16', partition) + # install bootloader + command('syslinux', partition) + + # mount the filesystem and create the files on it + with tempfile.TemporaryDirectory() as tmpdir: + command('mount', partition, tmpdir) + try: + command( + 'dd', + 'bs=1M', + 'if=' + os.path.join(cfg.path.work, 'initrd.cpio'), + 'of=' + os.path.join(tmpdir, 'initrd'), + 'oflag=direct', + 'status=progress', + ) + command( + 'dd', + 'bs=1M', + 'if=' + os.path.join(cfg.path.initrd, 'vmlinuz'), + 'of=' + os.path.join(tmpdir, 'kernel'), + 'oflag=direct', + 'status=progress', + ) + + cmdline = " ".join([ + # the client system shouldn't modeset. + # if it modesets, then the early boot steps of the actual target + # initrd won't have working video output, making debugging + # them harder. + "nomodeset", + "systemd.unit=stiefel-client.service", + ]) + + with open(os.path.join(tmpdir, 'syslinux.cfg'), 'w') as syslinuxcfg: + syslinuxcfg.write(f"default kernel initrd=initrd {cmdline}\n") + finally: + command('umount', tmpdir) + + print("synching io buffers...") + os.sync() + print("done.") diff --git a/util.py b/stiefelsystem/util.py similarity index 92% rename from util.py rename to stiefelsystem/util.py index 0efc6e0..c3fecc8 100644 --- a/util.py +++ b/stiefelsystem/util.py @@ -2,6 +2,7 @@ Utilities for use by the various scripts. """ import io +import logging import multiprocessing import os import pathlib @@ -13,7 +14,30 @@ import traceback import urllib.request -from config import CONFIG as cfg + +def log_setup(setting, default=1): + """ + Perform setup for the logger. + Run before any logging.log thingy is called. + + if setting is 0: the default is used, which is WARNING. + else: setting + default is used. + """ + + levels = (logging.ERROR, logging.WARNING, logging.INFO, + logging.DEBUG, logging.NOTSET) + + factor = clamp(default + setting, 0, len(levels) - 1) + level = levels[factor] + + logging.basicConfig(level=level, format="[%(asctime)s] %(message)s") + logging.error("loglevel: %s", logging.getLevelName(level)) + logging.captureWarnings(True) + + +def clamp(number, smallest, largest): + """ return number but limit it to the inclusive given value range """ + return max(smallest, min(number, largest)) def warn(message): @@ -102,7 +126,7 @@ def command(*cmd, silent=False, nspawn=None, shell=False, confirm=False, print("\x1b[33;1mwill run:\x1b[m", end=" ") else: print("\x1b[32;1m$\x1b[m", end=" ") - print(" ".join(shlex.quote(part) for part in cmd)) + print(" ".join(shlex.quote(str(part)) for part in cmd)) if confirm: if not get_consent(): return @@ -125,10 +149,12 @@ def command(*cmd, silent=False, nspawn=None, shell=False, confirm=False, return stdout -def initrd_write(path, *lines, content=None, append=False): +def initrd_write(basedir, path, *lines, content=None, append=False): """ Writes a file in the initrd. + @param basedir + path to the initrd fs root @param path must be an absolute string (starting with '/'). @param lines @@ -163,7 +189,7 @@ def prepare_line(line): mode = 'wb' print('\x1b[33;1m' + mode + '\x1b[m ' + path) - with open(cfg.path.initrd + path, mode) as fileobj: + with open(basedir + path, mode) as fileobj: fileobj.write(content) @@ -386,7 +412,7 @@ def quote(entry): def add_or_edit_var(self, varname, value, add_prefix=''): """ - edits or creates a bash variable assignment such as foo="asdf" + edits or creates a bash variable assignment such as foo="rolf" """ match = re.search(fr'\n{varname}="(.*?)"'.encode(), self.data) if match is None: @@ -417,7 +443,7 @@ def install_folder(source, dest="/"): else: print(f'skipping install of {source!r} to {dest!r}') return - + for entry in os.listdir(source): source_path = os.path.join(source, entry) dest_path = os.path.join(dest, entry) @@ -570,3 +596,17 @@ def mac_to_v6ll(mac): low0 = mac_nr >> 16 & 0xff return f'fe80::{high1:04x}:{high0:02x}ff:fe{low0:02x}:{low1:04x}' + + +def ensure_single_system(cfg): + """ + verify there's exactly one system-* module enabled. + it determines the distro we operate for. + """ + selected_system_modules = sum( + [1 if mod.startswith('system-') else 0 + for mod in cfg.modules]) + + if selected_system_modules != 1: + warn("Please select exactly one system-* module") + raise SystemExit(1) diff --git a/test-nspawn b/test-nspawn deleted file mode 100755 index abd87d3..0000000 --- a/test-nspawn +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -import argparse - -from config import CONFIG as cfg -from util import ( - command, - ensure_root, -) - -ensure_root() - -cli = argparse.ArgumentParser() -cli.add_argument('--target', default=cfg.path.initrd) -args = cli.parse_args() - -command('-b', nspawn=args.target) diff --git a/test-qemu b/test-qemu deleted file mode 100755 index a7cc2c0..0000000 --- a/test-qemu +++ /dev/null @@ -1,449 +0,0 @@ -#!/usr/bin/env python3 -""" -script to test the server and client in qemu. - -run this in two terminals. -first you need to start the server: - -sudo ./qemu server - -then, run - -sudo ./qemu client - -they should be able to talk to each other. -the client should download the kernel and initrd from the server, -and proceed up to `kexec`. -""" -import argparse -import base64 -import contextlib -import json -import os -import tempfile - -from config import CONFIG as cfg -from util import ( - command, - ensure_root, - install_binary, - mac_to_v6ll, -) - -ensure_root() - -cli = argparse.ArgumentParser() -cli.add_argument('--kernel', default=cfg.path.initrd + '/vmlinuz', - help='the kernel to be provided to a stiefel-client') -cli.add_argument('--initrd', default=cfg.path.cpio, - help='the initrd to be delivered to a stiefel-client') - -cli.add_argument('--srv-kernel', default=cfg.path.initrd + '/vmlinuz', - help='the kernel to booted for this stiefel-server') -cli.add_argument('--srv-initrd', default=cfg.path.cpio, - help='the initrd to loaded to this stiefel-server') - -cli.add_argument('mode', choices=['client', 'server']) -cli.add_argument('--cmdline', - help='extra kernel cmdline args for the final system') -cli.add_argument('--forward-usb') -cli.add_argument('--clientkexecinvocation', action='store_true', - help=("when set, get a qemu invocation to directly test " - "a stiefel-client kexec run")) -cli.add_argument('--graphical', action='store_true') -cli.add_argument('--serialnetwork', action='store_true', - help="attach ttyS0 to the VM, exposed as telnet tcp port") -cli.add_argument('--skip-confirm', action='store_true') -cli.add_argument('--extra-files') -args = cli.parse_args() - - -with contextlib.ExitStack() as exit_stack: - tmpdir = exit_stack.enter_context(tempfile.TemporaryDirectory()) - def tmppath(name): - return os.path.join(tmpdir, name) - - # this is passed to the kernel to be stiefeled. - # they are used in addition to the options served by - # stiefel-server from /boot/stiefelsystem.json - inner_cmdline_l = [ - "vga=795", - "console=ttyS0", - ] - - if 'lvm' in cfg.modules: - inner_cmdline_l.append("root=/dev/mapper/qemustiefel-root") - else: - inner_cmdline_l.append("root=/dev/sda2") - - if not args.graphical: - inner_cmdline_l.append("console=ttyS0") - - if args.cmdline: - inner_cmdline_l.append(args.cmdline) - - inner_cmdline = " ".join(inner_cmdline_l) - - client_mac = "52:54:00:5f:70:02" - - bootpart_luks = None - bootpart_luks_uuid = None - - if args.mode == 'client': - # client mode only works when a server is already running - - # check if our test might reboot the actual device... - autokexec_active = command("systemctl", "is-active", "stiefel-autokexec.service", - get_retval=True) - if autokexec_active == 0: - print("stiefel-autokexec.service is active on your computer") - print("this test would thus reboot your device during testing!") - print("-> please run:\nsystemctl stop stiefel-autokexec.service") - exit(1) - - mac = client_mac - - inner_cmdline_b64 = base64.b64encode(inner_cmdline.encode('utf-8')).decode('ascii') - - cmdline = [ - "systemd.unit=stiefel-client.service", - "nomodeset", - "stiefel_innercmdline=" + inner_cmdline_b64, - ] - qemu_args = ['-gdb', 'tcp::1234'] - - # qemu boots this kernel - kernel = args.srv_kernel - initrd = args.srv_initrd - - - elif args.mode == 'server': - mac = "52:54:00:5f:70:03" - - with contextlib.ExitStack() as setup_exit_stack: - - # 4 GiB sparse image - disk_size = 4 * 1024 ** 3 - - # create the disk image that will be served to the server - with open(tmppath('loop_file'), 'wb') as loop_file: - loop_file.truncate(disk_size) - - # create partitions in loop file - - if 'lvm' in cfg.modules: - # one gpt partition for the whole disk - command('sgdisk', '-n', '0:0:0', loop_file.name) - else: - # gpt boot and root partition - # 1G boot, the rest for root (like lvm below) - command( - 'sgdisk', '-n', '0:0:+1G', '-n', '0:0:0', loop_file.name - ) - - # create the loop device - loop_device_name = command( - 'losetup', '-fP', '--show', loop_file.name, - capture_stdout=True - ).decode().strip() - - setup_exit_stack.callback( - lambda: command('losetup', '-d', loop_device_name)) - - bootpartition_name = loop_device_name + 'p1' - rootpartition_name = loop_device_name + 'p2' - - if cfg.boot.luks_block is not None: - luks_password = "sft.lol" - command('echo', 'The LUKS password is: ', luks_password) - command('cryptsetup', 'luksFormat', bootpartition_name, '-', - stdin=f'{luks_password}') - - luks_mapped_name = 'stiefel-qemu-root' - command('cryptsetup', 'open', '--type=luks', '--key-file=-', - bootpartition_name, luks_mapped_name, - stdin=f'{luks_password}') - setup_exit_stack.callback( - lambda: command('cryptsetup', 'close', luks_mapped_name)) - - bootpart_luks = bootpartition_name - bootpartition_name = f'/dev/mapper/{luks_mapped_name}' - - bootpart_luks_uuid = command( - 'blkid', - '--output', 'value', - '--match-tag', 'UUID', - bootpart_luks, - capture_stdout=True - ).decode().strip() - - if 'lvm' in cfg.modules: - command('pvcreate', bootpartition_name) - pv_device = bootpartition_name - setup_exit_stack.callback( - lambda: command('pvremove', pv_device)) - - vg_name = 'qemustiefel' - command('vgcreate', vg_name, bootpartition_name) - # use extreme caution here: this removes a vg! - setup_exit_stack.callback( - lambda: command('vgremove', '-y', vg_name)) - - setup_exit_stack.callback( - lambda: command('cp', '--sparse=always', - loop_file.name, tmppath('disk_image'))) - - setup_exit_stack.callback( - lambda: command('vgchange', '--activate=n', vg_name)) - - # like the gpt partitions above - command('lvcreate', '-n', 'boot', '-L', '1G', vg_name) - command('lvcreate', '-n', 'root', '-L', '2G', vg_name) - - rootpartition_name = '/dev/mapper/qemustiefel-root' - bootpartition_name = '/dev/mapper/qemustiefel-boot' - - else: - setup_exit_stack.callback( - lambda: command('mv', loop_file.name, tmppath('disk_image'))) - - # create filesystem on the partition - command('mkfs.vfat', '-F', '16', bootpartition_name) - command('mkfs.ext4', rootpartition_name) - - # mount boot filesystem, fill it with files, and unmount it - os.makedirs(tmppath('mntboot'), exist_ok=True) - command('mount', bootpartition_name, tmppath('mntboot')) - setup_exit_stack.callback( - lambda: command('umount', tmppath('mntboot'))) - - # mount root filesystem, so we can simulate a root-switch - os.makedirs(tmppath('mntroot'), exist_ok=True) - command('mount', rootpartition_name, tmppath('mntroot')) - setup_exit_stack.callback( - lambda: command('umount', tmppath('mntroot'))) - - # this is the to-be-stiefeled kernel and initrd, - # which will be provided to the stiefel-client. - command('cp', args.kernel, tmppath('mntboot') + '/kernel') - command('cp', args.initrd, tmppath('mntboot') + '/initrd') - - with open(tmppath('mntboot') + '/stiefelsystem.json', 'w') as fileobj: - # cmdline options served by stiefel-server - # to the booting client - stiefel_cmdline = [ - "verbose", - ] - - # TODO: actually should be 'initrd-dracut' - if 'system-gentoo' in cfg.modules or 'system-arch-dracut' in cfg.modules: - stiefel_cmdline.extend([ - "rd.info", - "rd.shell", - "rd.retry=15", - ]) - if bootpart_luks_uuid: - stiefel_cmdline.append(f"rd.luks.uuid={bootpart_luks_uuid}") - elif bootpart_luks_uuid: - raise NotImplementedError("luks unlocking not implemented for non-dracut initrd") - - if args.cmdline: - stiefel_cmdline.append(args.cmdline) - - json.dump({ - "kernel": "kernel", - "initrd": "initrd", - "cmdline": stiefel_cmdline, - "stiefelmodules": list(cfg.modules.keys()), - }, fileobj) - - # dummy root filesystem - command('mkdir', *[f"{tmppath('mntroot')}/{dirname}" for dirname in - ('etc', 'sbin', 'bin', 'sys', 'dev', 'proc', 'run', 'tmp', 'lib')]) - command('tee', tmppath('mntroot') + '/etc/os-release', - stdin='NAME=Stiefelsystem\nID=sftstiefel\nPRETTY_NAME="SFT Stiefelsystem"\n') - command('tee', tmppath('mntroot') + '/etc/fstab', - stdin='# lol nope\n') - - install_binary(tmppath('mntroot'), '/bin/sh') - command('tee', tmppath('mntroot') + '/sbin/init', - stdin=("#!/bin/sh\n" - "echo 'sft technologies is proud to announce:'\n" - "echo 'your system is now booted!'\n" - "echo 'it should now work with your real system.'\n" - "echo 'if not, sft technologies is sorry for you.'\n" - "exec /bin/sh\n" - "")) - command('chmod', '+x', tmppath('mntroot') + '/sbin/init') - - # setup the bridge that allows connection to the client VM - command('ip', 'link', 'add', 'name', 'br0', 'type', 'bridge') - command('ip', 'link', 'set', 'dev', 'br0', 'up') - command('ip', 'a', 'a', '10.4.5.1/24', 'dev', 'br0') - exit_stack.callback(lambda: command('ip', 'link', 'delete', 'br0')) - - # because inside qemu the loop-device is called sda - if bootpartition_name == loop_device_name + 'p1': - bootpartition_name = "/dev/sda1" - - if bootpart_luks == loop_device_name + 'p1': - bootpart_luks = "/dev/sda1" - - cmdline = [ - "stiefel_bootdisk=/dev/sda", - f"stiefel_bootpart={bootpartition_name}", - ] - cmdline.append("systemd.unit=stiefel-server.service") - - if cfg.boot.luks_block is None: - # TODO: stiefel-server requires a password entry from cryptsetup - # thus we need to launch it manually... - pass - - else: - cmdline.append( - f'stiefel_bootpart_luks={bootpart_luks}' - ) - - qemu_args = [ - "-drive", f"file={tmppath('disk_image')},format=raw,if=none,id=systemhdd", - "-device", "virtio-scsi-pci,id=scsi0", - "-device", "scsi-hd,drive=systemhdd,bus=scsi0.0", - ] - - # qemu boots this kernel - kernel = args.srv_kernel - initrd = args.srv_initrd - - elif args.mode == 'test': - mac = "52:54:00:5f:70:04" - - # network link makes sense to have even in the test vm - command('ip', 'link', 'add', 'name', 'br0', 'type', 'bridge') - command('ip', 'link', 'set', 'dev', 'br0', 'up') - command('ip', 'a', 'a', '10.4.5.1/24', 'dev', 'br0') - exit_stack.callback(lambda: command('ip', 'link', 'delete', 'br0')) - - cmdline = [] - qemu_args = [] - - else: - cli.error("mode must be 'client', 'server' or 'test'") - - with open(tmppath('ifup-script'), 'w') as ifup_script: - ifup_script.writelines([ - "#!/bin/sh\n", - "ip l set $1 up\n", - "ip l set dev $1 master br0\n", - "ip l set br0 up\n", - ]) - os.chmod(tmppath('ifup-script'), 0o755) - - if args.forward_usb is not None: - try: - vid, did = args.forward_usb.split(':') - if len(vid) != 4 or len(did) != 4: - raise ValueError() - except ValueError: - cli.error("--forward-usb expects VID:DID") - - qemu_args.extend([ - "-usb", - "-device", f"usb-host,vendorid=0x{vid},productid=0x{did}" - ]) - - if args.extra_files is not None: - # pack the extra files into an CPIO archive - extra_cpio = command( - "find . -depth -print0 | bsdcpio -0 -o -H newc | gzip -1", - shell=True, - cwd=args.extra_files, - capture_stdout=True, - env={"LANG": "C"}, - ) - with open(args.initrd, 'rb') as initrd_rfile: - args.initrd = tmppath('initrd-concatenated') - with open(args.initrd, 'wb') as initrd_wfile: - initrd_wfile.write(initrd_rfile.read()) - initrd_wfile.write(extra_cpio) - - if args.serialnetwork: - qemu_args.extend([ - "-serial", - "telnet:localhost:4321,server,wait" - ]) - - if not args.graphical: - cmdline.insert(0, "console=ttyS0") - qemu_args.append("-nographic") - confirm = not (args.serialnetwork or args.skip_confirm) - - print("remember: quit qemu with C-a x") - else: - qemu_args.extend([ - "-vga", "virtio", - ]) - cmdline.append("vga=795") - confirm = False - - qemu_base = [ - "qemu-system-x86_64", - "-machine", "q35,accel=kvm", - "-cpu", "host", - "-m", str(3 * 1024 if args.mode == "server" else 5 * 1024), - ] - qemu_kernel = [ - "-kernel", args.srv_kernel, - "-initrd", args.srv_initrd, - ] - qemu_netdev = [ - "-netdev", f"tap,id=network0,script={tmppath('ifup-script')},downscript=no", - ] - qemu_devices = [ - "-device", f"virtio-net,netdev=network0,mac={mac}", - ] - qemu_cmdline = [ - "-append", " ".join(cmdline), - ] - - if args.clientkexecinvocation: - # this is mostly useful for debugging the "real" initrd: - # it starts the machine just like after stiefelsystem - # kexec'd your real kernel+initramfs. - print("invocation for qemu to directly execute the " - "to-be-stiefeled client+initramfs:") - print() - - # we should directly get this from stiefel-client code! - # instead we have to copy it :( - if any(mod in ('system-gentoo', 'system-arch-dracut') for mod in cfg.modules): - inner_cmdline += ( - " ifname=stiefellink:" + client_mac + - " ip=stiefellink:link6" + - " netroot=nbd:[" + mac_to_v6ll(mac) + "%stiefellink]:stiefelblock:::-persist" - ) - - if bootpart_luks_uuid: - inner_cmdline += f" rd.luks.uuid={bootpart_luks_uuid}" - - client_kexec = qemu_base + [ - # handy for debugging, but not necessary - "-gdb tcp::1234 -serial telnet:localhost:4321,server,wait -nographic" - ] + [ - "-kernel", args.kernel, - "-initrd", args.initrd, - ] + qemu_netdev + [ - "-device", f"virtio-net,netdev=network0,mac={client_mac}", - ] + ["-append", f"'{inner_cmdline}'"] - - print(" ".join(client_kexec)) - - input("[enter] to continue") - - qemu_launch = (qemu_base + qemu_kernel + qemu_netdev + - qemu_devices + qemu_cmdline + qemu_args) - - command( - *qemu_launch, - confirm=confirm, - )