Skip to content

Latest commit

 

History

100 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

portlin

Portlin writes a real, upgradable Debian + Xfce install onto a USB stick that boots on any x86_64 machine, BIOS or UEFI, optionally with a LUKS2-encrypted root.

Not a live ISO with a persistence overlay. Kernel upgrades work. apt full-upgrade works. Think Rufus' "Windows To Go", for Linux.

sudo portlin create --target /dev/sdb --encrypt

Boot the result anywhere and a first-run wizard asks for an account, keyboard, language and time zone, then offers to grow the system to fill the drive.

Requirements

x86_64 Linux host, root, and these packages:

sudo apt install debootstrap gdisk parted dosfstools e2fsprogs cryptsetup zstd
git clone https://github.com/sleep/Portlin && cd Portlin
sudo python3 -m portlin doctor

doctor reports everything missing at once, and names the package that provides it.

Commands

portlin doctor                                   # check the host
portlin devices                                  # list candidate targets
portlin build -o rootfs.tar.zst                  # slow half, 20-40 min
portlin write -t /dev/sdb --rootfs rootfs.tar.zst --encrypt   # fast half, ~2 min
portlin create -t /dev/sdb --encrypt             # both, in one step

build produces a hardware-agnostic, identity-free tarball. Making a second stick, or redoing one after a mistake, does not mean waiting for another debootstrap.

Flags

Flag Effect
--encrypt LUKS2 over root. Prompts for a passphrase on the terminal
--minimal No desktop. Boot, system, storage and network only
--groups desktop,apps Pick package groups explicitly
--extra tmux --exclude firefox-esr Adjust the package set
--suite bookworm Build a different Debian release
--target stick.img Write to an image file instead of a device
--dry-run Print every command that would run, and stop

There is deliberately no --passphrase flag: it would be readable from /proc by every user on the machine and would land in shell history.

What lands on the stick

GPT, four partitions:

# Size Type Purpose
1 1 MiB EF02 BIOS boot, holds GRUB's core.img
2 512 MiB EF00 ESP, FAT32, /boot/efi
3 1 GiB ext4 /boot, plaintext
4 rest ext4 or LUKS2 root

The image ships at 8 GB no matter how big the stick is, so one image fits every drive and the flash is fast. On first boot it offers to expand into the rest.

The Xfce desktop is dark out of the box: Numix across GTK, window decorations, the LightDM greeter and the terminal, with Papirus-Dark icons over it. First boot offers three widget themes and five icon sets, all of them installed in the image, because first boot has no network. The defaults live in /etc/xdg/xdg-portlin, which the session adds to XDG_CONFIG_DIRS, so Settings > Appearance still changes them and the change sticks -- including a change made by the wizard, since everything that names a theme is a conffile. They sit in a directory of their own because dpkg lets only one installed package own a path, and Xfce's own packages already own the canonical /etc/xdg locations.

One panel, along the top, with a searchable applications menu under the portlin mark. At the right end sits a readout of what the machine is doing:

cpu 14%  mem 3.1G/15.5G  gpu 22%  disk 6.1G/6.5G of 119.2G  ip 192.168.1.42  bat 87%

It is one program rather than a row of plugins, so it has one font and one spacing rule, and so it can report things no stock plugin does. disk is the stick's own story: two capacities mean the image has not been expanded into the drive yet, and the tooltip says so and names portlin-expand. luks appears in the disk field on an encrypted stick, in crimson, which is the only crimson on the desktop. Clicking anything in the line opens About Portlin, which names the machine the stick is plugged into today: model, CPU, memory, graphics and whether it booted UEFI or BIOS.

The readout would rather say nothing than guess. cpu --% is the first couple of seconds of a session, before there are two samples to compare; a machine with no battery has no bat field rather than an empty one. Intel graphics expose a clock and no utilisation counter, so on those machines the field reads gpu 350MHz and the tooltip says in words that it is a frequency.

A coffee cup sits in the panel: click it and the machine stops sleeping, blanking and dimming until you click it again, or for a span you pick from Activate for. It holds a logind lock over idle:sleep:handle-lid-switch, so a closed lid does not suspend either. Right-click for preferences; untick it in Settings > Session and Startup to stop it appearing at all.

Software

Software, in the applications menu, installs the things people go looking for on a fresh system: Chrome, Brave, Chromium, Tor Browser, Pale Moon, Signal, Telegram, Discord, VLC, OBS, LibreOffice, GIMP, VS Code, Zed, Cursor, Claude Desktop, Claude Code, Kimi Code, Docker, RustDesk, AnyDesk, Mullvad, Tailscale, qBittorrent, Deluge and more. Each entry says where it comes from, because they are not all the same kind of thing: Debian's archive, the vendor's apt repository, a .deb the vendor publishes, a tarball unpacked into /opt, or an installer that runs as you, under your own home directory.

It also looks at the machine the stick is plugged into. The Drivers page names the graphics and wifi hardware it found and offers what fits: NVIDIA's proprietary driver, picked for that exact card by nvidia-detect, video acceleration and Vulkan for Intel and AMD, the Broadcom STA driver for the chips the open ones do not cover, and printing and scanning. A stick travels, so the NVIDIA entry says plainly what installing it does to the next machine, and how to undo it from a text console.

Everything privileged goes through one command, portlin-install, which the window runs under pkexec, or under sudo if first boot was told sudo needs no password. So the program that can be asked to act as root is one command with a fixed set of verbs rather than a window, and the same verbs work from a terminal:

portlin-install list                 # the catalog, and what is already installed
portlin-install install mullvad      # or remove, or status
portlin-install scan                 # what this machine needs
portlin-install upgrade              # what Update everything runs

Update everything in the window's header is that last one: apt full-upgrade with its output in the log pane, for the system and everything installed from here alike. A full upgrade is occasionally allowed to remove a package to resolve a transition, so it asks apt what it would do first and stops with the list in front of you rather than removing anything from a button press.

Entries that need Debian's non-free component get it through a drop-in under /etc/apt/sources.list.d/, so sticks written before that component was enabled by default are not left out. Deleting that file takes it away again.

Updates

The Debian system updates itself: it is a real install, so apt full-upgrade and kernel upgrades work.

Portlin's own contribution to the stick is split in two. The desktop theme, the wallpapers, the caffeine applet, the Software app and its catalog, the About Portlin menu entry and the portlin-info, portlin-expand, portlin-encrypt and portlin-install commands are Debian packages, and will update from portlin's archive like anything else once that archive is published; until then they stay at whatever version the stick was written with. The bootloader, the initramfs, fstab and crypttab are written once and stay put, because an update that breaks one of those is a stick that will not boot. Moving those forward means writing the stick again.

Safety

write erases whatever you point it at. Before it does:

  • non-removable devices are refused unless you pass --force
  • devices under 8 GiB are refused outright, --force or not
  • devices with mounted filesystems are refused outright
  • the confirmation prompt requires typing the device path, not y

Any failure mid-write unwinds in exact reverse: unmount, close the LUKS mapping, detach the loop device.

Development

make image     # build a real image, with progress
make test      # unit tests, no root, no Linux, ~1s
make dryrun    # print the full command plan
make check     # tests plus shellcheck
make harness   # shipped scripts against real loop devices, needs Docker

make image runs the whole pipeline and shows where it is: a bar per stage, the current package, and an ETA. On a host that cannot build directly (anything but x86_64 Linux as root, which includes every Mac) it re-runs itself inside a privileged linux/amd64 container and renders the same display from in there, so the same command works everywhere.

One stage cannot be drawn: the container has to install python3 before anything can draw at all, and on an emulated host that is among the slowest minutes of a build. Those steps narrate themselves instead, with the same elapsed-time format the display uses ( [4s] installing python3), and a cold image pull is announced before it starts rather than looking like a stall. make image ARGS=--verbose gives back the full apt output for debugging the bootstrap itself.

Every percentage comes from the tool doing the work rather than from a guess about phases: apt's APT::Status-Fd stream, debootstrap's package lines, tar's checkpoints. The overall ETA is the one estimate, weighted by how long each stage took on this machine last time; the first build has no history and says so.

Unit tests run anywhere, including macOS, because every external command goes through one Runner that can record instead of execute. That makes build_rootfs and write_stick assertable as ordered command lists, which is where the real risk lives: a crypttab written after update-initramfs produces a stick that cannot unlock itself, and no type checker finds that.


How it boots on machines it has never seen
  • Both firmware families. GRUB is installed twice: i386-pc into the MBR gap, and x86_64-efi --removable --no-nvram for UEFI. --removable writes EFI/BOOT/BOOTX64.EFI, the fallback path every UEFI implementation probes on removable media. --no-nvram keeps grub-install out of the build machine's firmware.
  • MODULES=most in the initramfs, so every storage and USB controller driver is present.
  • GRUB_DISABLE_OS_PROBER=true, so the menu does not advertise the build host's operating systems and os-prober never mounts a stranger's internal disks.
  • UUIDs everywhere. /dev/sda4 is correct on exactly one machine.
  • RESUME=none, so the initramfs does not stall hunting for someone else's hibernation image.
  • Firmware and microcode for everyone: iwlwifi, realtek, atheros, brcm80211, plus both intel-microcode and amd-microcode.
  • A capped LUKS KDF. cryptsetup sizes argon2id by benchmarking whichever machine formats the container, so a stick formatted on a workstation can be unopenable on a netbook. Portlin caps it at 256 MiB.
  • Flash-aware defaults: noatime,commit=120, and zram instead of swap.
Why /boot is not encrypted

GRUB can only read LUKS2 with the old PBKDF2 derivation, and unlocking in GRUB means typing the passphrase twice at every boot. The cost of a plaintext /boot is that someone with physical access can tamper with your kernel. The benefit is one prompt, a modern KDF, and the same arrangement the Debian installer itself produces.

How expansion works

Each layer can only grow into space the layer beneath it has already claimed, so the order is not a preference:

  1. growpart grows the last partition, via the kernel's live partition-resize ioctl.
  2. cryptsetup resize grows the LUKS mapping into the enlarged partition.
  3. resize2fs grows the filesystem into the enlarged mapping.

On an unencrypted stick, step 2 is skipped. Declining is safe and repeatable: nothing depends on having expanded, and the same three commands work later by hand.

First boot in detail

The image ships with no user, an empty machine-id, no SSH host keys and a locked root account. portlin-firstboot.service runs on tty1 before LightDM and collects the account, hostname, locale, keyboard, time zone and whether sudo should ask that account for a password. On an encrypted stick it also offers to change the LUKS passphrase, so the person holding the stick owns the key rather than whoever built it.

If the wizard is cancelled or crashes its sentinel stays in place and it runs again next boot, rather than stranding you at a login screen with no accounts. On a stick encrypted during that boot but never finished, the initramfs recognises the situation and asks for the passphrase itself. Otherwise no crypttab would exist yet, nothing would unlock the root, and cancelling a wizard would leave an unbootable drive.

Verification tiers
Tier Needs Command
Unit nothing make test
Real-device harnesses Docker, privileged make harness
Structural Linux, root sudo scripts/verify-image.sh stick.img
Integration Linux or Docker, privileged ROOTFS=... scripts/integration-test.sh
End to end qemu make prove

make harness earns its keep. It runs the shipped scripts and packages against real block devices, a real dpkg and a real X server, in about three minutes. Seven harnesses, ten runs:

Harness What only a real device shows
test-encrypt-hook.py the initramfs encryption script end to end: prompts, fsck, shrink, encrypt, unlock, mount, and a canary file proving the data survived
test-expand.py the expansion path against a real mounted filesystem. Four runs: the wizard's apply_expand and the packaged portlin-expand are separate implementations that can drift, each tested encrypted and not
test-stash-passphrase.py the crypttab keyscript, whose stdout is key material, and that the passphrase reaches the wizard without ever becoming a file on the stick
test-package-conflicts.py portlin's packages installing where something else already owns /etc/xdg
test-package-upgrade.py conffiles surviving an upgrade, against a real dpkg
test-caffeine.py the applet actually moving the screen settings, against a real X server
test-software.py portlin-install installing and removing real packages from Debian's archive and from a vendor repository, every package name in the catalog resolving, both privilege refusals, and the Software window against a real X server

Between them they caught a malformed partx argument, lsblk -o PKNAME returning empty for device-mapper volumes, an assumption that udev had created /dev/mapper symlinks, a filesystem left larger than its container, and cryptsetup resize silently prompting for a passphrase with no terminal. Every one had already reached a USB stick, because the only test covering them was a fifteen-minute emulated boot.

That last one is now fixed rather than merely survived: the volume key is unreachable once the initramfs has exited, so whatever saw the passphrase there leaves it in /run for the wizard to finish the expansion with, and the wizard deletes it on first use. On an ordinary boot that is a crypttab keyscript; on the boot that creates the container it is the encryption hook itself, which runs before any crypttab exists to name a keyscript. The wizard also gives every command it runs a pipe on stdin rather than the tty1 it was handed, so cryptsetup cannot take the console and ask for the passphrase over the top of the dialogs.

verify-image.sh is the one to run after any change to the write path: it loop-mounts a finished image and checks the things that only show up as "this machine won't boot it" - the UEFI fallback file, GRUB in the MBR, the BIOS boot partition type, UUID-only fstab, an empty machine-id, an armed wizard.

qemu-boot-test.sh boots the image under both legacy BIOS and UEFI/OVMF and asserts GRUB reaches its menu on each. Those are genuinely different code paths and a stick can work on one while being invisible to the other.

Building on an arm64 Mac

Not natively, since debootstrap runs amd64 maintainer scripts. A privileged linux/amd64 container works end to end, at emulation speed:

docker run --rm --privileged --platform linux/amd64 \
    -v "$PWD:/src" -v "$PWD/out:/out" -w /src debian:trixie bash -c '
        apt-get update -qq
        apt-get install -y -qq --no-install-recommends python3 debootstrap gdisk \
            parted dosfstools e2fsprogs cryptsetup-bin util-linux zstd tar mount
        python3 -m portlin build --minimal -o /out/rootfs.tar.zst
        python3 -m portlin write -t /out/stick.img --image-size 12G \
            --rootfs /out/rootfs.tar.zst --yes
        bash scripts/verify-image.sh /out/stick.img'

A container has no udev and mounts /dev as a plain tmpfs, so partition nodes are never created automatically. Portlin handles that itself: it waits for the nodes, nudges the kernel with partx, and finally creates them from /sys/class/block/<name>/dev the way udev would.

Then boot the result natively, since qemu on the Mac emulates x86_64 fine:

brew install qemu
scripts/qemu-boot-test.sh out/stick.img

Out of scope

A shared FAT32/exFAT partition readable from Windows, Secure Boot signing, and architectures other than amd64.

License

GNU General Public License v3.0 or later. The Debian system portlin installs onto the stick carries its own licenses, unaffected by this one.

About

A real, upgradable Debian + Xfce install on a USB stick that boots any x86_64 machine, optionally LUKS2 encrypted

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages