feat(image): flash full disk images into LUKS; add Raspberry Pi OS
Support distributions that ship a full .img (Raspberry Pi OS, moode, RetroPie, Manjaro ARM) rather than a rootfs tarball, by loop-mounting the image and copying its boot + root partitions into a fresh LUKS container. - distributions.yml/catalog.py/choosers.py: raspios catalog (lite64/desktop64/ lite32 via the stable _latest redirects) + choose_raspios. - plan.py: source_url override so a _latest redirect downloads under an .img.xz name that decompress_command recognises. - transfer.py: transfer_disk_image (loop-mount -> repartition -> LUKS -> rsync copy with progress, cp fallback -> fix boot fstab); transfer_image gains interactive= and routes encrypted non-arch images here; download_image gains force_prompt. - loopimg.py: losetup attach/detach/partition helper. - fsutil.drop_fstab_mount + register_encrypted_root: replace a stock image's existing / fstab line instead of colliding with it. - encryption.configure_encryption returns the onion address. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,5 +27,9 @@ def retropie_images() -> dict[str, dict[str, str]]:
|
|||||||
return _load()["retropie_images"]
|
return _load()["retropie_images"]
|
||||||
|
|
||||||
|
|
||||||
|
def raspios_images() -> dict[str, dict[str, str]]:
|
||||||
|
return _load()["raspios_images"]
|
||||||
|
|
||||||
|
|
||||||
def mkinitcpio_modules_by_rpi() -> dict[str, str]:
|
def mkinitcpio_modules_by_rpi() -> dict[str, str]:
|
||||||
return _load()["mkinitcpio_modules_by_rpi"]
|
return _load()["mkinitcpio_modules_by_rpi"]
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ retropie_images:
|
|||||||
checksum: b5daa6e7660a99c246966f3f09b4014b
|
checksum: b5daa6e7660a99c246966f3f09b4014b
|
||||||
image: retropie-buster-4.8-rpi4_400.img.gz
|
image: retropie-buster-4.8-rpi4_400.img.gz
|
||||||
|
|
||||||
|
# Raspberry Pi OS. The "_latest" endpoints 302-redirect to the current release;
|
||||||
|
# image is only the local filename (must end .img.xz for decompress_command).
|
||||||
|
raspios_images:
|
||||||
|
lite64:
|
||||||
|
source_url: https://downloads.raspberrypi.com/raspios_lite_arm64_latest
|
||||||
|
image: raspios_lite_arm64_latest.img.xz
|
||||||
|
desktop64:
|
||||||
|
source_url: https://downloads.raspberrypi.com/raspios_arm64_latest
|
||||||
|
image: raspios_arm64_latest.img.xz
|
||||||
|
lite32:
|
||||||
|
source_url: https://downloads.raspberrypi.com/raspios_lite_armhf_latest
|
||||||
|
image: raspios_lite_armhf_latest.img.xz
|
||||||
|
|
||||||
# Extra kernel modules the initramfs needs for early network access,
|
# Extra kernel modules the initramfs needs for early network access,
|
||||||
# see https://raspberrypi.stackexchange.com/questions/67051
|
# see https://raspberrypi.stackexchange.com/questions/67051
|
||||||
mkinitcpio_modules_by_rpi:
|
mkinitcpio_modules_by_rpi:
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ def replace_in_file(search: str, replace: str, path: str | Path) -> None:
|
|||||||
path.write_text(new_text)
|
path.write_text(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_fstab_mount(mount_point: str, path: str | Path) -> None:
|
||||||
|
"""Remove any (non-comment) fstab line whose mount point field equals mount_point.
|
||||||
|
|
||||||
|
Lets a fresh mapper/boot entry replace a stock image's existing one instead
|
||||||
|
of colliding with it. No-op when the file or a matching line is absent.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
kept = [
|
||||||
|
line
|
||||||
|
for line in path.read_text().splitlines()
|
||||||
|
if line.lstrip().startswith("#") or line.split()[1:2] != [mount_point]
|
||||||
|
]
|
||||||
|
path.write_text("".join(f"{line}\n" for line in kept))
|
||||||
|
|
||||||
|
|
||||||
def ensure_line_in_file(line: str, path: str | Path) -> bool:
|
def ensure_line_in_file(line: str, path: str | Path) -> bool:
|
||||||
"""Append ``line`` unless already present. Returns True when appended."""
|
"""Append ``line`` unless already present. Returns True when appended."""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ def choose_retropie(plan: ImagePlan) -> None:
|
|||||||
plan.image_name = entry["image"]
|
plan.image_name = entry["image"]
|
||||||
|
|
||||||
|
|
||||||
|
def choose_raspios(plan: ImagePlan) -> None:
|
||||||
|
plan.boot_size = "+512M"
|
||||||
|
plan.root_filesystem = "ext4"
|
||||||
|
variant = ui.ask("Which Raspberry Pi OS image [lite64,desktop64,lite32]:")
|
||||||
|
entry = catalog.raspios_images().get(variant)
|
||||||
|
if entry is None:
|
||||||
|
raise LimError(f"Raspberry Pi OS variant {variant} isn't supported.")
|
||||||
|
plan.source_url = entry["source_url"]
|
||||||
|
plan.image_name = entry["image"]
|
||||||
|
|
||||||
|
|
||||||
def choose_torbox(plan: ImagePlan) -> None:
|
def choose_torbox(plan: ImagePlan) -> None:
|
||||||
plan.base_download_url = "https://www.torbox.ch/data/"
|
plan.base_download_url = "https://www.torbox.ch/data/"
|
||||||
plan.image_name = "torbox-20220102-v050.gz"
|
plan.image_name = "torbox-20220102-v050.gz"
|
||||||
@@ -83,12 +94,13 @@ DISTRIBUTION_CHOOSERS = {
|
|||||||
"manjaro": choose_manjaro,
|
"manjaro": choose_manjaro,
|
||||||
"moode": choose_moode,
|
"moode": choose_moode,
|
||||||
"retropie": choose_retropie,
|
"retropie": choose_retropie,
|
||||||
|
"raspios": choose_raspios,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def choose_linux_image(plan: ImagePlan) -> None:
|
def choose_linux_image(plan: ImagePlan) -> None:
|
||||||
distribution = ui.ask(
|
distribution = ui.ask(
|
||||||
"Which distribution should be used [arch,moode,retropie,manjaro,torbox...]?"
|
"Which distribution should be used [arch,raspios,moode,retropie,manjaro,torbox...]?"
|
||||||
)
|
)
|
||||||
chooser = DISTRIBUTION_CHOOSERS.get(distribution)
|
chooser = DISTRIBUTION_CHOOSERS.get(distribution)
|
||||||
if chooser is None:
|
if chooser is None:
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from lim.image.plan import ImagePlan
|
|||||||
from lim.image.session import ImageSession, install_packages
|
from lim.image.session import ImageSession, install_packages
|
||||||
|
|
||||||
|
|
||||||
def configure_encryption(plan: ImagePlan, session: ImageSession, authorized_keys: Path) -> None:
|
def configure_encryption(
|
||||||
|
plan: ImagePlan, session: ImageSession, authorized_keys: Path
|
||||||
|
) -> str | None:
|
||||||
|
"""Configure the remote-unlock LUKS stack; return the onion address, if any."""
|
||||||
root = session.root_mount_path
|
root = session.root_mount_path
|
||||||
backend = get_backend(plan.distribution)
|
backend = get_backend(plan.distribution)
|
||||||
ui.info("Setup encryption...")
|
ui.info("Setup encryption...")
|
||||||
@@ -26,12 +29,14 @@ def configure_encryption(plan: ImagePlan, session: ImageSession, authorized_keys
|
|||||||
|
|
||||||
backend.install_authorized_key(root, authorized_keys)
|
backend.install_authorized_key(root, authorized_keys)
|
||||||
|
|
||||||
|
onion_address = None
|
||||||
if plan.tor_unlock:
|
if plan.tor_unlock:
|
||||||
# Hook files and onion keys must exist before the initramfs is baked.
|
# Hook files and onion keys must exist before the initramfs is baked.
|
||||||
backend.install_tor_unlock(plan, root)
|
onion_address = backend.install_tor_unlock(plan, root)
|
||||||
|
|
||||||
# crypttab must be written before the initramfs is (re)generated so the
|
# crypttab must be written before the initramfs is (re)generated so the
|
||||||
# Debian cryptsetup hook can bake the mapping in.
|
# Debian cryptsetup hook can bake the mapping in.
|
||||||
backend.register_encrypted_root(plan, session, root)
|
backend.register_encrypted_root(plan, session, root)
|
||||||
backend.configure_initramfs(plan, root)
|
backend.configure_initramfs(plan, root)
|
||||||
backend.configure_bootloader(plan, session, root)
|
backend.configure_bootloader(plan, session, root)
|
||||||
|
return onion_address
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ class InitramfsToolsBackend(InitramfsBackend):
|
|||||||
" defaults,noatime 0 1"
|
" defaults,noatime 0 1"
|
||||||
)
|
)
|
||||||
ui.info(f"Configuring {fstab_path}...")
|
ui.info(f"Configuring {fstab_path}...")
|
||||||
|
# A stock image already mounts / by PARTUUID; drop it so the mapper wins.
|
||||||
|
fsutil.drop_fstab_mount("/", fstab_path)
|
||||||
fsutil.ensure_line_in_file(fstab_line, fstab_path)
|
fsutil.ensure_line_in_file(fstab_line, fstab_path)
|
||||||
|
|
||||||
crypttab_path = root / "etc/crypttab"
|
crypttab_path = root / "etc/crypttab"
|
||||||
|
|||||||
28
lim/image/loopimg.py
Normal file
28
lim/image/loopimg.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Attach a stock disk image as a loop device to read its partitions."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lim import runner, ui
|
||||||
|
from lim.errors import LimError
|
||||||
|
|
||||||
|
|
||||||
|
def attach(image_path: Path) -> str:
|
||||||
|
"""Attach the image via losetup with partition scanning; return /dev/loopN."""
|
||||||
|
ui.info(f"Attaching {image_path} as a loop device...")
|
||||||
|
loop = runner.output(
|
||||||
|
["losetup", "-Pf", "--show", str(image_path)],
|
||||||
|
sudo=True,
|
||||||
|
error_msg=f"Attaching {image_path} as a loop device failed.",
|
||||||
|
).strip()
|
||||||
|
if not loop:
|
||||||
|
raise LimError(f"losetup returned no device for {image_path}.")
|
||||||
|
return loop
|
||||||
|
|
||||||
|
|
||||||
|
def detach(loop: str) -> None:
|
||||||
|
runner.run(["losetup", "-d", loop], sudo=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def partition(loop: str, number: int) -> str:
|
||||||
|
"""/dev/loop0 -> /dev/loop0p1 (losetup -P names partitions with a p suffix)."""
|
||||||
|
return f"{loop}p{number}"
|
||||||
@@ -12,6 +12,10 @@ class ImagePlan:
|
|||||||
distribution: str | None = None
|
distribution: str | None = None
|
||||||
base_download_url: str | None = None
|
base_download_url: str | None = None
|
||||||
image_name: str | None = None
|
image_name: str | None = None
|
||||||
|
# Direct download URL when it cannot be derived as base_download_url + image_name
|
||||||
|
# (Raspberry Pi OS ships behind a "_latest" redirect but must land under a
|
||||||
|
# .img.xz name so decompress_command recognises it).
|
||||||
|
source_url: str | None = None
|
||||||
image_checksum: str | None = None
|
image_checksum: str | None = None
|
||||||
boot_size: str = ""
|
boot_size: str = ""
|
||||||
luks_memory_cost: str | None = None
|
luks_memory_cost: str | None = None
|
||||||
@@ -23,6 +27,8 @@ class ImagePlan:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def download_url(self) -> str | None:
|
def download_url(self) -> str | None:
|
||||||
|
if self.source_url is not None:
|
||||||
|
return self.source_url
|
||||||
if self.base_download_url is None or self.image_name is None:
|
if self.base_download_url is None or self.image_name is None:
|
||||||
return None
|
return None
|
||||||
return f"{self.base_download_url}{self.image_name}"
|
return f"{self.base_download_url}{self.image_name}"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"""Download the selected image and transfer it onto the target device."""
|
"""Download the selected image and transfer it onto the target device."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from lim import device as device_module
|
from lim import device as device_module
|
||||||
from lim import runner, ui
|
from lim import runner, ui
|
||||||
from lim.errors import LimError
|
from lim.errors import LimError
|
||||||
|
from lim.image import loopimg
|
||||||
from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan
|
from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan
|
||||||
from lim.image.session import ImageSession
|
from lim.image.session import ImageSession
|
||||||
|
|
||||||
|
|
||||||
def download_image(plan: ImagePlan) -> None:
|
def download_image(plan: ImagePlan, *, force_prompt: bool = True) -> None:
|
||||||
if ui.confirm("Should the image download be forced?"):
|
if force_prompt and ui.confirm("Should the image download be forced?"):
|
||||||
if plan.image_path.is_file():
|
if plan.image_path.is_file():
|
||||||
ui.info(f"Removing image {plan.image_path}.")
|
ui.info(f"Removing image {plan.image_path}.")
|
||||||
plan.image_path.unlink()
|
plan.image_path.unlink()
|
||||||
@@ -116,19 +118,107 @@ def transfer_arch_image(plan: ImagePlan, session: ImageSession) -> None:
|
|||||||
runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True)
|
runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True)
|
||||||
|
|
||||||
|
|
||||||
def transfer_image(plan: ImagePlan, session: ImageSession) -> None:
|
def _create_encrypted_layout(plan: ImagePlan, session: ImageSession) -> None:
|
||||||
if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"):
|
"""Partition the target, LUKS-format the root, format and mount both."""
|
||||||
ui.info("Deleting...")
|
boot_size = plan.boot_size or DEFAULT_BOOT_SIZE
|
||||||
runner.run(["wipefs", "-a", session.device.path], sudo=True)
|
ui.info(f"Creating partitions (boot {boot_size})...")
|
||||||
else:
|
runner.run(
|
||||||
ui.info("Skipping partition table deletion...")
|
["fdisk", session.device.path],
|
||||||
|
input_text=arch_partition_input(boot_size),
|
||||||
|
sudo=True,
|
||||||
|
)
|
||||||
|
runner.run(["mkfs.vfat", session.boot_partition_path], sudo=True)
|
||||||
|
_luks_format_root(plan, session)
|
||||||
|
session.decrypt_root()
|
||||||
|
runner.run([f"mkfs.{plan.root_filesystem}", "-F", session.root_mapper_path], sudo=True)
|
||||||
|
session.mount_partitions()
|
||||||
|
|
||||||
device_module.overwrite_device(session.device)
|
|
||||||
|
def _copy_tree(source: str, target: str, flags: list[str]) -> None:
|
||||||
|
"""Copy a directory's contents, showing progress when rsync is available."""
|
||||||
|
if shutil.which("rsync"):
|
||||||
|
runner.run(["rsync", *flags, "--info=progress2", f"{source}/", f"{target}/"], sudo=True)
|
||||||
|
else:
|
||||||
|
runner.run(["cp", "-a", f"{source}/.", target], sudo=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_boot_fstab(session: ImageSession) -> None:
|
||||||
|
"""Repoint the stock boot fstab line at the freshly created boot partition.
|
||||||
|
|
||||||
|
The copied image mounts /boot(/firmware) by the stock PARTUUID, which no
|
||||||
|
longer exists after repartitioning; swap in the new boot UUID, mount point
|
||||||
|
unchanged. No-op when the image carries no boot line.
|
||||||
|
"""
|
||||||
|
fstab = session.root_mount_path / "etc/fstab"
|
||||||
|
if not fstab.is_file():
|
||||||
|
return
|
||||||
|
lines = []
|
||||||
|
for line in fstab.read_text().splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if not line.lstrip().startswith("#") and fields[1:2] and fields[1].startswith("/boot"):
|
||||||
|
fields[0] = f"UUID={session.boot_partition_uuid}"
|
||||||
|
lines.append(" ".join(fields))
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
fstab.write_text("".join(f"{entry}\n" for entry in lines))
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_disk_image(plan: ImagePlan, session: ImageSession) -> None:
|
||||||
|
"""Copy a full disk image (boot + root partitions) into a fresh encrypted layout.
|
||||||
|
|
||||||
|
Distributions that ship a partitioned .img rather than a rootfs tarball
|
||||||
|
(Raspberry Pi OS, moode, RetroPie, Manjaro ARM) are loop-mounted and their
|
||||||
|
first two partitions copied into a new LUKS container.
|
||||||
|
"""
|
||||||
|
raw = plan.image_path.with_suffix("")
|
||||||
|
ui.info(f"Decompressing {plan.image_path} to {raw}...")
|
||||||
|
runner.pipeline(
|
||||||
|
decompress_command(plan.image_path),
|
||||||
|
["dd", f"of={raw}", "bs=4M", "conv=fsync", "status=progress"],
|
||||||
|
error_msg=f"Decompressing {plan.image_path} failed.",
|
||||||
|
)
|
||||||
|
loop = loopimg.attach(raw)
|
||||||
|
source_boot = session.working_folder / "source-boot"
|
||||||
|
source_root = session.working_folder / "source-root"
|
||||||
|
try:
|
||||||
|
source_boot.mkdir()
|
||||||
|
source_root.mkdir()
|
||||||
|
runner.run(["mount", "-o", "ro", loopimg.partition(loop, 1), str(source_boot)], sudo=True)
|
||||||
|
runner.run(["mount", "-o", "ro", loopimg.partition(loop, 2), str(source_root)], sudo=True)
|
||||||
|
|
||||||
|
_create_encrypted_layout(plan, session)
|
||||||
|
|
||||||
|
ui.info("Copying the root filesystem into the encrypted container...")
|
||||||
|
_copy_tree(str(source_root), str(session.root_mount_path), ["-aHAX"])
|
||||||
|
ui.info("Copying the boot partition...")
|
||||||
|
_copy_tree(str(source_boot), str(session.boot_mount_path), ["-a"])
|
||||||
|
runner.sync_disks()
|
||||||
|
_fix_boot_fstab(session)
|
||||||
|
finally:
|
||||||
|
runner.run(["umount", "-v", str(source_boot)], sudo=True, check=False)
|
||||||
|
runner.run(["umount", "-v", str(source_root)], sudo=True, check=False)
|
||||||
|
loopimg.detach(loop)
|
||||||
|
for path in (source_boot, source_root):
|
||||||
|
runner.run(["rmdir", str(path)], sudo=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_image(plan: ImagePlan, session: ImageSession, *, interactive: bool = True) -> None:
|
||||||
|
if interactive:
|
||||||
|
if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"):
|
||||||
|
ui.info("Deleting...")
|
||||||
|
runner.run(["wipefs", "-a", session.device.path], sudo=True)
|
||||||
|
else:
|
||||||
|
ui.info("Skipping partition table deletion...")
|
||||||
|
device_module.overwrite_device(session.device)
|
||||||
|
|
||||||
ui.info("Starting image transfer...")
|
ui.info("Starting image transfer...")
|
||||||
if plan.distribution == "arch":
|
if plan.distribution == "arch":
|
||||||
transfer_arch_image(plan, session)
|
transfer_arch_image(plan, session)
|
||||||
return
|
return
|
||||||
|
if plan.encrypt_system:
|
||||||
|
# Non-tarball distros ship a full .img; copy it into a LUKS container.
|
||||||
|
transfer_disk_image(plan, session)
|
||||||
|
return
|
||||||
blocksize = session.device.optimal_blocksize
|
blocksize = session.device.optimal_blocksize
|
||||||
ui.info(f"Transfering {plan.image_path.suffix} file...")
|
ui.info(f"Transfering {plan.image_path.suffix} file...")
|
||||||
runner.pipeline(
|
runner.pipeline(
|
||||||
|
|||||||
@@ -33,3 +33,25 @@ def test_ensure_line_creates_file_and_handles_missing_newline(tmp_path):
|
|||||||
target.write_text("no newline at end")
|
target.write_text("no newline at end")
|
||||||
assert fsutil.ensure_line_in_file("second", target) is True
|
assert fsutil.ensure_line_in_file("second", target) is True
|
||||||
assert target.read_text() == "no newline at end\nsecond\n"
|
assert target.read_text() == "no newline at end\nsecond\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_fstab_mount_removes_matching_mount_only(tmp_path):
|
||||||
|
target = tmp_path / "fstab"
|
||||||
|
target.write_text(
|
||||||
|
"# comment / stays\n"
|
||||||
|
"PARTUUID=aa-02 / ext4 defaults 0 1\n"
|
||||||
|
"PARTUUID=aa-01 /boot/firmware vfat defaults 0 2\n"
|
||||||
|
)
|
||||||
|
fsutil.drop_fstab_mount("/", target)
|
||||||
|
remaining = target.read_text()
|
||||||
|
assert "PARTUUID=aa-02" not in remaining
|
||||||
|
assert "# comment / stays" in remaining
|
||||||
|
assert "/boot/firmware" in remaining
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_fstab_mount_is_noop_when_absent(tmp_path):
|
||||||
|
target = tmp_path / "fstab"
|
||||||
|
target.write_text("PARTUUID=aa-01 /boot vfat defaults 0 2\n")
|
||||||
|
fsutil.drop_fstab_mount("/", target)
|
||||||
|
assert target.read_text() == "PARTUUID=aa-01 /boot vfat defaults 0 2\n"
|
||||||
|
fsutil.drop_fstab_mount("/", tmp_path / "missing") # no crash
|
||||||
|
|||||||
Reference in New Issue
Block a user