feat(image): distro-agnostic remote unlock via initramfs backends + Debian support
Split the mkinitcpio-only remote-LUKS-unlock path into an InitramfsBackend ABC with a get_backend() dispatch, and add the initramfs-tools backend for Debian / Raspberry Pi OS. - base.py: six-step backend contract; encryption.py becomes a thin, distro-neutral sequencer (get_backend by distribution). - initramfs_tools.py: crypttab `none luks,initramfs`, cmdline rewritten to root=/dev/mapper + ip=::::host:eth0:dhcp, dropbear-initramfs authorized_keys, update-initramfs -k all (no build-host uname leak). - shipped hooks (configuration/initramfs-tools/*): single-hop non-anonymous onion, libnss DNS baking, sed-not-source DHCP, kill-tor-before-pivot. - shared offline onion keygen in keygen.py; tor.py removed (logic moved to mkinitcpio.py). - raspios added to the apt distro family (session.py, raspberry.py). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
21
lim/image/initramfs/__init__.py
Normal file
21
lim/image/initramfs/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Distro-specific initramfs backends for encrypted remote unlock."""
|
||||
|
||||
from lim.errors import LimError
|
||||
from lim.image.initramfs.base import InitramfsBackend
|
||||
from lim.image.initramfs.initramfs_tools import InitramfsToolsBackend
|
||||
from lim.image.initramfs.mkinitcpio import MkinitcpioBackend
|
||||
|
||||
_MKINITCPIO = frozenset({"arch", "manjaro"})
|
||||
_INITRAMFS_TOOLS = frozenset({"raspios", "moode", "retropie"})
|
||||
|
||||
|
||||
def get_backend(distribution: str | None) -> InitramfsBackend:
|
||||
"""Pick the initramfs backend for a distribution's init system."""
|
||||
if distribution in _MKINITCPIO:
|
||||
return MkinitcpioBackend()
|
||||
if distribution in _INITRAMFS_TOOLS:
|
||||
return InitramfsToolsBackend()
|
||||
raise LimError(f"No initramfs backend for distribution {distribution!r}.")
|
||||
|
||||
|
||||
__all__ = ["InitramfsBackend", "get_backend"]
|
||||
42
lim/image/initramfs/base.py
Normal file
42
lim/image/initramfs/base.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""The initramfs backend interface.
|
||||
|
||||
Encrypted remote-unlock images differ by init system: Arch/Manjaro use
|
||||
mkinitcpio, Debian/Raspberry Pi OS use initramfs-tools. Each backend owns the
|
||||
distro-specific bits (package set, crypttab syntax, initramfs generation,
|
||||
bootloader cmdline, and how the Tor hook is baked in); the encryption flow
|
||||
orchestrates them the same way for every distro.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import ImageSession
|
||||
|
||||
|
||||
class InitramfsBackend(ABC):
|
||||
"""Distro-specific steps to build an encrypted, remote-unlockable image."""
|
||||
|
||||
@abstractmethod
|
||||
def luks_package_collection(self) -> str:
|
||||
"""Name of the package collection providing the LUKS/unlock stack."""
|
||||
|
||||
@abstractmethod
|
||||
def install_authorized_key(self, root: Path, authorized_keys: Path) -> None:
|
||||
"""Place the SSH public key where the initramfs dropbear reads it."""
|
||||
|
||||
@abstractmethod
|
||||
def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str:
|
||||
"""Bake the Tor onion service into the initramfs; return the address."""
|
||||
|
||||
@abstractmethod
|
||||
def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
|
||||
"""Write fstab and crypttab in the distro's syntax."""
|
||||
|
||||
@abstractmethod
|
||||
def configure_initramfs(self, plan: ImagePlan, root: Path) -> None:
|
||||
"""Configure the initramfs modules/hooks and (re)generate it."""
|
||||
|
||||
@abstractmethod
|
||||
def configure_bootloader(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
|
||||
"""Point the bootloader at the encrypted root with early networking."""
|
||||
115
lim/image/initramfs/initramfs_tools.py
Normal file
115
lim/image/initramfs/initramfs_tools.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""initramfs-tools backend (Debian / Raspberry Pi OS).
|
||||
|
||||
Debian does not use mkinitcpio; the LUKS unlock stack is cryptsetup-initramfs
|
||||
(reads /etc/crypttab, option ``initramfs``) + dropbear-initramfs (serves the
|
||||
passphrase prompt over SSH). Raspberry Pi OS ships without an initramfs, so we
|
||||
enable one in config.txt and regenerate with update-initramfs.
|
||||
|
||||
NOTE: the exact dropbear-initramfs key path, the boot-partition mount point
|
||||
(/boot vs /boot/firmware) and the initramfs enable knob vary across Raspberry
|
||||
Pi OS releases; verify on the target release. This path is not exercised by the
|
||||
QEMU harness yet.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from lim import config, fsutil, packages, runner, ui
|
||||
from lim.image.initramfs import keygen
|
||||
from lim.image.initramfs.base import InitramfsBackend
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import ImageSession, chroot_bash, install_packages
|
||||
|
||||
|
||||
class InitramfsToolsBackend(InitramfsBackend):
|
||||
def luks_package_collection(self) -> str:
|
||||
return "server/luks-debian"
|
||||
|
||||
def install_authorized_key(self, root: Path, authorized_keys: Path) -> None:
|
||||
# dropbear-initramfs (bookworm) reads /etc/dropbear/initramfs/authorized_keys.
|
||||
target = root / "etc/dropbear/initramfs/authorized_keys"
|
||||
ui.info(f"Adding {authorized_keys} to {target}...")
|
||||
runner.run(
|
||||
["install", "-D", "-m", "0600", str(authorized_keys), str(target)],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str:
|
||||
ui.info("Setting up remote unlock via Tor onion service (initramfs-tools)...")
|
||||
install_packages(
|
||||
plan.distribution, root, " ".join(packages.get_packages("server/tor-debian"))
|
||||
)
|
||||
source_dir = config.CONFIGURATION_PATH / "initramfs-tools"
|
||||
for source, target, mode in (
|
||||
(source_dir / "tor_hook", root / "etc/initramfs-tools/hooks/tor", "0755"),
|
||||
(
|
||||
source_dir / "tor_premount",
|
||||
root / "etc/initramfs-tools/scripts/init-premount/tor",
|
||||
"0755",
|
||||
),
|
||||
(
|
||||
source_dir / "tor_bottom",
|
||||
root / "etc/initramfs-tools/scripts/init-bottom/tor",
|
||||
"0755",
|
||||
),
|
||||
(source_dir / "torrc", root / keygen.TORRC_STAGING_PATH, "0644"),
|
||||
):
|
||||
ui.info(f"Installing {target}...")
|
||||
runner.run(["install", "-D", "-m", mode, str(source), str(target)], sudo=True)
|
||||
onion_address = keygen.generate_onion_keys(root)
|
||||
ui.success(f"Onion unlock address: {onion_address}")
|
||||
ui.info(f"Unlock later with: torsocks ssh root@{onion_address}")
|
||||
return onion_address
|
||||
|
||||
def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
|
||||
fstab_path = root / "etc/fstab"
|
||||
fstab_line = (
|
||||
f"/dev/mapper/{session.root_mapper_name} / {plan.root_filesystem}"
|
||||
" defaults,noatime 0 1"
|
||||
)
|
||||
ui.info(f"Configuring {fstab_path}...")
|
||||
fsutil.ensure_line_in_file(fstab_line, fstab_path)
|
||||
|
||||
crypttab_path = root / "etc/crypttab"
|
||||
# `initramfs` bakes the mapping into the initramfs so cryptsetup-initramfs
|
||||
# unlocks the root before the pivot.
|
||||
crypttab_line = (
|
||||
f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks,initramfs"
|
||||
)
|
||||
ui.info(f"Configuring {crypttab_path}...")
|
||||
fsutil.ensure_line_in_file(crypttab_line, crypttab_path)
|
||||
|
||||
def configure_initramfs(self, plan: ImagePlan, root: Path) -> None: # noqa: ARG002
|
||||
ui.info("Regenerating the Debian initramfs (update-initramfs)...")
|
||||
# Create for all installed kernels when none exists yet (Raspberry Pi OS
|
||||
# ships without one), else update the existing one. `-k all` avoids the
|
||||
# build host's `uname -r` leaking into the chroot.
|
||||
chroot_bash(
|
||||
root,
|
||||
"update-initramfs -c -k all 2>/dev/null || update-initramfs -u -k all",
|
||||
)
|
||||
|
||||
def configure_bootloader(
|
||||
self,
|
||||
plan: ImagePlan, # noqa: ARG002
|
||||
session: ImageSession,
|
||||
root: Path,
|
||||
) -> None:
|
||||
hostname = (root / "etc/hostname").read_text().strip()
|
||||
boot = session.boot_mount_path
|
||||
|
||||
config_txt = boot / "config.txt"
|
||||
if config_txt.is_file():
|
||||
ui.info(f"Enabling an initramfs in {config_txt}...")
|
||||
fsutil.ensure_line_in_file("auto_initramfs=1", config_txt)
|
||||
|
||||
cmdline_txt = boot / "cmdline.txt"
|
||||
ui.info(f"Configuring {cmdline_txt}...")
|
||||
text = cmdline_txt.read_text().strip()
|
||||
text = re.sub(r"root=\S+", f"root=/dev/mapper/{session.root_mapper_name}", text)
|
||||
if "ip=" not in text:
|
||||
text = f"{text} ip=::::{hostname}:eth0:dhcp"
|
||||
if "net.ifnames=0" not in text:
|
||||
text = f"{text} net.ifnames=0"
|
||||
cmdline_txt.write_text(text + "\n")
|
||||
ui.info(f"Content of {cmdline_txt}:{cmdline_txt.read_text()}")
|
||||
54
lim/image/initramfs/keygen.py
Normal file
54
lim/image/initramfs/keygen.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Offline Tor onion key generation, shared by every initramfs backend.
|
||||
|
||||
``tor --DisableNetwork 1`` writes a v3 onion service's keys without touching
|
||||
the network, so the same stable ``.onion`` address can be baked into the
|
||||
initramfs whether the distro uses mkinitcpio or initramfs-tools.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lim import ui
|
||||
from lim.errors import LimError
|
||||
from lim.image.session import chroot_bash
|
||||
|
||||
# Staged relative to the mounted image root.
|
||||
ONION_STAGING_DIR = "etc/tor/initramfs-onion"
|
||||
TORRC_STAGING_PATH = "etc/tor/initramfs-torrc"
|
||||
|
||||
# An empty -f torrc keeps the image's /etc/tor/torrc (User tor, ...) out of the
|
||||
# keygen run, which would otherwise fail as non-root inside the chroot.
|
||||
_KEYGEN_SCRIPT = f"""
|
||||
mkdir -p /{ONION_STAGING_DIR}
|
||||
chmod 0700 /{ONION_STAGING_DIR}
|
||||
: > /tmp/tor-keygen-torrc
|
||||
tor -f /tmp/tor-keygen-torrc --DisableNetwork 1 \\
|
||||
--DataDirectory /tmp/tor-keygen-data \\
|
||||
--HiddenServiceDir /{ONION_STAGING_DIR} \\
|
||||
--HiddenServicePort "22 127.0.0.1:22" \\
|
||||
--SocksPort 0 --RunAsDaemon 0 --Log "notice stderr" &
|
||||
tor_pid=$!
|
||||
for _ in $(seq 1 30); do
|
||||
[ -s /{ONION_STAGING_DIR}/hostname ] && break
|
||||
sleep 1
|
||||
done
|
||||
kill "$tor_pid" 2>/dev/null || true
|
||||
rm -rf /tmp/tor-keygen-data /tmp/tor-keygen-torrc
|
||||
[ -s /{ONION_STAGING_DIR}/hostname ]
|
||||
"""
|
||||
|
||||
|
||||
def generate_onion_keys(root: Path) -> str:
|
||||
"""Generate the onion keys offline in the chroot; return the .onion address.
|
||||
|
||||
Idempotent: existing keys are kept so the address stays stable across
|
||||
re-runs.
|
||||
"""
|
||||
hostname_path = root / ONION_STAGING_DIR / "hostname"
|
||||
if hostname_path.is_file():
|
||||
ui.info("Onion keys already exist, keeping the existing address.")
|
||||
else:
|
||||
ui.info("Generating onion service keys (offline, inside the chroot)...")
|
||||
chroot_bash(root, _KEYGEN_SCRIPT, error_msg="Onion key generation failed.")
|
||||
if not hostname_path.is_file():
|
||||
raise LimError(f"Onion key generation produced no {hostname_path}.")
|
||||
return hostname_path.read_text().strip()
|
||||
122
lim/image/initramfs/mkinitcpio.py
Normal file
122
lim/image/initramfs/mkinitcpio.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""mkinitcpio backend (Arch Linux ARM / Manjaro ARM).
|
||||
|
||||
See https://gist.github.com/gea0/4fc2be0cb7a74d0e7cc4322aed710d38 and
|
||||
https://gist.github.com/EnigmaCurry/2f9bed46073da8e38057fe78a61e7994
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lim import catalog, config, fsutil, packages, runner, ui
|
||||
from lim.image.initramfs import keygen
|
||||
from lim.image.initramfs.base import InitramfsBackend
|
||||
from lim.image.plan import ImagePlan
|
||||
from lim.image.session import ImageSession, chroot_bash, install_packages
|
||||
|
||||
MKINITCPIO_HOOKS_PREFIX = (
|
||||
"base udev autodetect microcode modconf kms keyboard keymap consolefont block"
|
||||
)
|
||||
MKINITCPIO_HOOKS_SUFFIX = "filesystems fsck"
|
||||
|
||||
|
||||
class MkinitcpioBackend(InitramfsBackend):
|
||||
def luks_package_collection(self) -> str:
|
||||
return "server/luks"
|
||||
|
||||
def install_authorized_key(self, root: Path, authorized_keys: Path) -> None:
|
||||
dropbear_root_key_path = root / "etc/dropbear/root_key"
|
||||
ui.info(f"Adding {authorized_keys} to dropbear...")
|
||||
runner.run(["cp", "-v", str(authorized_keys), str(dropbear_root_key_path)], sudo=True)
|
||||
|
||||
def install_tor_unlock(self, plan: ImagePlan, root: Path) -> str:
|
||||
ui.info("Setting up remote unlock via Tor onion service...")
|
||||
install_packages(plan.distribution, root, " ".join(packages.get_packages("server/tor")))
|
||||
source_dir = config.CONFIGURATION_PATH / "initcpio"
|
||||
for source, target in (
|
||||
(source_dir / "tor_install", root / "etc/initcpio/install/tor"),
|
||||
(source_dir / "tor_hook", root / "etc/initcpio/hooks/tor"),
|
||||
(source_dir / "torrc", root / keygen.TORRC_STAGING_PATH),
|
||||
):
|
||||
ui.info(f"Installing {target}...")
|
||||
runner.run(["install", "-D", "-m", "0644", str(source), str(target)], sudo=True)
|
||||
onion_address = keygen.generate_onion_keys(root)
|
||||
ui.success(f"Onion unlock address: {onion_address}")
|
||||
ui.info(f"Unlock later with: torsocks ssh root@{onion_address}")
|
||||
return onion_address
|
||||
|
||||
def register_encrypted_root(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
|
||||
fstab_path = root / "etc/fstab"
|
||||
fstab_line = (
|
||||
f"UUID={session.root_partition_uuid} / {plan.root_filesystem}"
|
||||
" defaults,noatime 0 1"
|
||||
)
|
||||
ui.info(f"Configuring {fstab_path}...")
|
||||
fsutil.ensure_line_in_file(fstab_line, fstab_path)
|
||||
ui.info(f"Content of {fstab_path}:{fstab_path.read_text()}")
|
||||
|
||||
crypttab_path = root / "etc/crypttab"
|
||||
crypttab_line = f"{session.root_mapper_name} UUID={session.root_partition_uuid} none luks"
|
||||
ui.info(f"Configuring {crypttab_path}...")
|
||||
fsutil.ensure_line_in_file(crypttab_line, crypttab_path)
|
||||
ui.info(f"Content of {crypttab_path}:{crypttab_path.read_text()}")
|
||||
|
||||
def configure_initramfs(self, plan: ImagePlan, root: Path) -> None:
|
||||
# Concerning mkinitcpio warnings, see
|
||||
# https://gist.github.com/imrvelj/c65cd5ca7f5505a65e59204f5a3f7a6d
|
||||
mkinitcpio_path = root / "etc/mkinitcpio.conf"
|
||||
ui.info(f"Configuring {mkinitcpio_path}...")
|
||||
additional_modules = catalog.mkinitcpio_modules_by_rpi().get(plan.raspberry_pi_version)
|
||||
if additional_modules is None:
|
||||
ui.warning(f"Version {plan.raspberry_pi_version} isn't supported.")
|
||||
additional_modules = ""
|
||||
modules = f"g_cdc usb_f_acm usb_f_ecm {additional_modules} g_ether".replace(" ", " ")
|
||||
fsutil.replace_in_file("MODULES=()", f"MODULES=({modules})", mkinitcpio_path)
|
||||
fsutil.replace_in_file("BINARIES=()", "BINARIES=(/usr/lib/libgcc_s.so.1)", mkinitcpio_path)
|
||||
tor_hook = "tor " if plan.tor_unlock else ""
|
||||
fsutil.replace_in_file(
|
||||
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} {MKINITCPIO_HOOKS_SUFFIX})",
|
||||
f"HOOKS=({MKINITCPIO_HOOKS_PREFIX} sleep netconf {tor_hook}dropbear "
|
||||
f"encryptssh {MKINITCPIO_HOOKS_SUFFIX})",
|
||||
mkinitcpio_path,
|
||||
)
|
||||
ui.info(f"Content of {mkinitcpio_path}:{mkinitcpio_path.read_text()}")
|
||||
ui.info("Generating mkinitcpio...")
|
||||
chroot_bash(root, "mkinitcpio -vP")
|
||||
|
||||
def configure_bootloader(self, plan: ImagePlan, session: ImageSession, root: Path) -> None:
|
||||
cryptdevice = (
|
||||
f"cryptdevice=UUID={session.root_partition_uuid}:{session.root_mapper_name} "
|
||||
f"root={session.root_mapper_path}"
|
||||
)
|
||||
boot_txt_path = session.boot_mount_path / "boot.txt"
|
||||
if boot_txt_path.is_file():
|
||||
ui.info(f"Configuring {boot_txt_path}...")
|
||||
hostname = (root / "etc/hostname").read_text().strip()
|
||||
fsutil.replace_in_file("part uuid ${devtype} ${devnum}:2 uuid", "", boot_txt_path)
|
||||
fsutil.replace_in_file(
|
||||
"setenv bootargs console=ttyS1,115200 console=tty0 root=PARTUUID=${uuid} "
|
||||
'rw rootwait smsc95xx.macaddr="${usbethaddr}"',
|
||||
f"setenv bootargs console=ttyS1,115200 console=tty0 "
|
||||
f"ip=::::{hostname}:eth0:dhcp {cryptdevice} rw rootwait "
|
||||
# Concerning issues with network adapter names, see
|
||||
# https://forum.iobroker.net/topic/40542/raspberry-pi4-kein-eth0-mehr/16
|
||||
'smsc95xx.macaddr="${usbethaddr}" net.ifnames=0 biosdevname=0',
|
||||
boot_txt_path,
|
||||
)
|
||||
ui.info(f"Content of {boot_txt_path}:{boot_txt_path.read_text()}")
|
||||
ui.info("Generating...")
|
||||
chroot_bash(root, "cd /boot/ && ./mkscr || exit 1")
|
||||
else:
|
||||
cmdline_txt_path = session.boot_mount_path / "cmdline.txt"
|
||||
ui.info(f"Configuring {cmdline_txt_path}...")
|
||||
# Firmware-boot boards (e.g. RPi4 cmdline.txt) need the same early
|
||||
# networking as the boot.txt branch, or the initramfs netconf hook
|
||||
# brings up no interface and remote unlock (dropbear + tor) is
|
||||
# unreachable — the onion never even publishes.
|
||||
hostname = (root / "etc/hostname").read_text().strip()
|
||||
fsutil.replace_in_file(
|
||||
"root=/dev/mmcblk0p2",
|
||||
f"{cryptdevice} rootfstype={plan.root_filesystem} "
|
||||
f"ip=::::{hostname}:eth0:dhcp net.ifnames=0 biosdevname=0",
|
||||
cmdline_txt_path,
|
||||
)
|
||||
ui.info(f"Content of {cmdline_txt_path}:{cmdline_txt_path.read_text()}")
|
||||
Reference in New Issue
Block a user