Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
image.oci
*.img
__pycache__/
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
binutils \
bubblewrap \
composefs \
cryptsetup-bin \
dmsetup \
dosfstools \
dracut \
e2fsprogs \
Expand All @@ -74,8 +76,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
skopeo \
systemd \
systemd-boot \
systemd-cryptsetup \
systemd-repart \
systemd-resolved \
systemd-timesyncd \
tpm2-tools \
ubuntu-minimal \
zstd

Expand Down
41 changes: 24 additions & 17 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ push := "false"
tag := "26.04"

oci_archive := "image.oci"
# Name and size of the disk image produced by `just disk`.
# Name of the disk image produced by `just disk`. bcvk sizes it for us.
disk_name := "ubuntu-bootc"
disk_size := "20G"
# Scratch space the install VM gets for unpacking the image.
scratch_size := "8G"

# List available recipes.
default:
Expand All @@ -33,21 +34,27 @@ load:
vm: load
bcvk ephemeral run-ssh docker.io/{{ image }}:{{ tag }}

# Install the image to a raw disk image that can be booted or written to a device.
disk:
truncate -s {{ disk_size }} {{ disk_name }}.img
docker run \
--rm --privileged \
-v /dev:/dev \
-v "$PWD:/output" \
{{ image }}:{{ tag }} \
bootc install to-disk \
--source-imgref oci-archive:/output/{{ oci_archive }}:{{ tag }} \
--target-imgref docker.io/{{ image }}:{{ tag }} \
--composefs-backend \
--karg console=ttyS0,115200 \
--karg console=tty0 \
--via-loopback /output/{{ disk_name }}.img
# Install the image to an encrypted raw disk image that can be booted or written
# to a device. Prompts for a passphrase. The install runs in a VM.
disk: load
#!/usr/bin/env bash
set -euo pipefail
# bcvk creates the image when it is missing, so removing it clears the
# previous run's partition table.
rm -f {{ disk_name }}.img
# In the VM /var is a tmpfs carved out of /run which is too small to
# unpack the image into. Give /var/tmp its own tmpfs backed by the swap
# device. This is what bcvk does in its own to-disk.
install="mount -t tmpfs -o size={{ scratch_size }} tmpfs /var/tmp && \
/usr/lib/ubuntu-bootc/install-encrypted.py \
--karg console=ttyS0,115200 --karg console=tty0 \
/dev/disk/by-id/virtio-target \
oci-archive:/run/virtiofs-mnt-repo/{{ oci_archive }}:{{ tag }} docker.io/{{ image }}:{{ tag }}"
bcvk ephemeral run-ssh --rm --add-swap {{ scratch_size }} \
--mount-disk-file "$PWD/{{ disk_name }}.img:target" \
--bind "$PWD:repo" \
docker.io/{{ image }}:{{ tag }} \
-t "$install"

# Boot the disk image from `just disk` in qemu, with the console on this terminal.
boot:
Expand Down
4 changes: 4 additions & 0 deletions rootfs/usr/lib/dracut/dracut.conf.d/10-bootc.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ systemdsystemunitdir=/usr/lib/systemd/system
# The bootc dracut module never auto-installs and must be requested. It sets up
# /sysroot as a composefs deployment and pulls in the erofs + overlay modules.
add_dracutmodules+=" bootc "

# Unlock a LUKS root in the initramfs. tpm2-tss is requested explicitly because
# its dracut module only installs itself when another module pulls it in.
add_dracutmodules+=" crypt systemd-cryptsetup tpm2-tss "
197 changes: 197 additions & 0 deletions rootfs/usr/lib/ubuntu-bootc/install-encrypted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Install a botc image onto a LUKS2-encrypted root.

Partitions a disk according to /usr/lib/ubuntu-bootc/repart.d, encrypts the root
with a passphrase, and installs the image into it. Run it from the image itself:

podman run --rm -it --privileged --pid=host --ipc=host \\
-v /dev:/dev -v /run/udev:/run/udev:ro \\
IMAGE /usr/lib/ubuntu-bootc/install-encrypted.py /dev/nvme0n1 IMGREF
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from getpass import getpass
from pathlib import Path

# Discoverable Partitions Specification type GUIDs, as written by repart.d.
ESP_TYPE = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
ROOT_TYPE = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"

DEFINITIONS = Path("/usr/lib/ubuntu-bootc/repart.d")
MAPPING = "ubuntu-bootc-root"

# bootc unpacks the source image here, and containers/image hardcodes the path
# rather than honouring TMPDIR.
SCRATCH = Path("/var/tmp")
SCRATCH_NEEDED = 3 * 1024**3


def partition_of_type(device: Path, type_guid: str) -> Path:
"""Find a partition by its type GUID."""
out = subprocess.run(
["lsblk", "--json", "--list", "--output", "PATH,PARTTYPE", device],
check=True,
capture_output=True,
text=True,
).stdout
for part in json.loads(out)["blockdevices"]:
if (part.get("parttype") or "").lower() == type_guid:
return Path(part["path"])
sys.exit(f"no partition of type {type_guid} on {device}")


def prompt_passphrase() -> str:
passphrase = getpass("Passphrase for the encrypted root: ")
if not passphrase:
sys.exit("Passphrase must not be empty.")
if passphrase != getpass("Repeat passphrase: "):
sys.exit("Passphrases do not match.")
return passphrase


def confirm(device: Path) -> None:
print(f"This destroys every partition on {device}:")
subprocess.run(["lsblk", "--output", "NAME,SIZE,FSTYPE,LABEL", device], check=False)
if input(f"Type {device} to confirm: ").strip() != str(device):
sys.exit("Aborted.")


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--karg",
action="append",
default=[],
metavar="KARG",
help="Extra kernel argument, repeatable.",
)
parser.add_argument("device", type=Path, help="Disk to install to.")
parser.add_argument(
"source_imgref",
help="Image to install, in containers-transports(5) form, such as "
"docker://docker.io/example/ubuntu-bootc:26.04",
)
parser.add_argument(
"target_imgref",
nargs="?",
help="Registry reference the installed system fetches updates from, such "
"as docker.io/example/ubuntu-bootc:26.04.",
)
return parser.parse_args()


def install(args: argparse.Namespace, keyfile: Path, target: Path) -> None:
print(f"Partitioning {args.device}")
subprocess.run(
[
"systemd-repart",
f"--definitions={DEFINITIONS}",
"--empty=force",
"--dry-run=no",
f"--key-file={keyfile}",
args.device,
],
check=True,
# composefs verifies the deployment with fs-verity, and repart does not
# enable the ext4 feature by default.
env=os.environ | {"SYSTEMD_REPART_MKFS_OPTIONS_EXT4": "-O verity"},
)
subprocess.run(["udevadm", "settle"], check=True)

esp = partition_of_type(args.device, ESP_TYPE)
root = partition_of_type(args.device, ROOT_TYPE)

# bootc is handed an already-open mapping and cannot tell the root is
# encrypted, so it never adds an unlock argument of its own.
luks_uuid = subprocess.run(
["cryptsetup", "luksUUID", root],
check=True,
capture_output=True,
text=True,
).stdout.strip()

print(f"Unlocking {root}")
subprocess.run(
["cryptsetup", "luksOpen", root, MAPPING, f"--key-file={keyfile}"], check=True
)
subprocess.run(["mount", f"/dev/mapper/{MAPPING}", target], check=True)

extra: list[str] = []
if args.target_imgref:
extra += ["--target-imgref", args.target_imgref]
elif not args.source_imgref.startswith(("docker://", "registry:")):
print(
"Warning: no target_imgref and source_imgref is not a registry "
"reference, so the installed system will have no usable update source.",
file=sys.stderr,
)

print(f"Installing {args.source_imgref}")
subprocess.run(
[
"bootc",
"install",
"to-filesystem",
"--composefs-backend",
"--karg",
f"rd.luks.uuid={luks_uuid}",
*(arg for karg in args.karg for arg in ("--karg", karg)),
"--source-imgref",
args.source_imgref,
"--skip-fetch-check",
*extra,
target,
],
check=True,
)

print(f"\nInstalled. ESP {esp}, encrypted root {root}.")
print("Add a TPM2 keyslot once Secure Boot is enabled with:")
print(f" systemd-cryptenroll --tpm2-device=auto {root}")


def main() -> None:
args = parse_args()

if os.geteuid() != 0:
sys.exit("must run as root")
if not args.device.is_block_device():
sys.exit(f"{args.device} is not a block device.")

free = shutil.disk_usage(SCRATCH).free
if free < SCRATCH_NEEDED:
sys.exit(
f"{SCRATCH} has {free // 1024**2}M free, the install needs about "
f"{SCRATCH_NEEDED // 1024**2}M there. Mount more space and retry."
)

confirm(args.device)
passphrase = prompt_passphrase()

with tempfile.TemporaryDirectory() as tmp:
# repart takes the key from a file. Writing the passphrase with no
# trailing newline makes the resulting keyslot an ordinary passphrase.
keyfile = Path(tmp) / "passphrase"
keyfile.write_text(passphrase)
keyfile.chmod(0o600)

target = Path(tmp) / "target"
target.mkdir()
try:
install(args, keyfile, target)
finally:
subprocess.run(["umount", "--recursive", target], check=False)
subprocess.run(["cryptsetup", "close", MAPPING], check=False)


if __name__ == "__main__":
main()
12 changes: 12 additions & 0 deletions rootfs/usr/lib/ubuntu-bootc/repart.d/10-esp.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Install-time layout, applied by passing systemd-repart --definitions. Kept out
# of /usr/lib/repart.d, where systemd-repart.service would otherwise apply it
# automatically at boot.
#
# systemd-boot keeps every kernel and initramfs on the ESP rather than a separate
# /boot, and bootc retains several deployments, so the usual 512M is too small.
[Partition]
Type=esp
Format=vfat
Label=esp
SizeMinBytes=1G
SizeMaxBytes=1G
13 changes: 13 additions & 0 deletions rootfs/usr/lib/ubuntu-bootc/repart.d/20-root.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# LUKS2 root filling the rest of the disk.
#
# Encrypt=key-file takes its key from systemd-repart --key-file. Passing the
# passphrase itself, with no trailing newline, makes that keyslot an ordinary
# passphrase. A TPM2 keyslot can be added later with systemd-cryptenroll.
#
# NOTE: the root filesystem needs verity. repart does not format with it by
# default, so run with SYSTEMD_REPART_MKFS_OPTIONS_EXT4="-O verity".
[Partition]
Type=root
Format=ext4
Label=root
Encrypt=key-file