<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Roos on How not to hack!</title><link>https://iwantimmer.nl/tags/roos/</link><description>Recent content in Roos on How not to hack!</description><generator>Hugo -- 0.154.5</generator><language>en-us</language><lastBuildDate>Tue, 27 Jan 2026 23:03:52 +0100</lastBuildDate><atom:link href="https://iwantimmer.nl/tags/roos/index.xml" rel="self" type="application/rss+xml"/><item><title>Building Custom Appliances: A Guide to Immutable Linux</title><link>https://iwantimmer.nl/posts/2026/building-custom-appliances-a-guide-to-immutable-linux/</link><pubDate>Tue, 27 Jan 2026 23:00:00 +0100</pubDate><guid>https://iwantimmer.nl/posts/2026/building-custom-appliances-a-guide-to-immutable-linux/</guid><description>&lt;p&gt;Managing a large set of systems has always been a hassle.
Although tools like Ansible and Puppet simplify the process significantly, it can still be time-consuming when managing tens of systems.&lt;/p&gt;
&lt;p&gt;Appliances designed to run specific software often start as a simple Debian installation.
The initial installation and configuration take a few hours, and updates usually need to be performed manually.
While this small investment of time is perfectly fine for a handful of systems, growing to tens of machines requires automating installations and enabling automatic (security) updates.
However, the moment you want—or need—to upgrade the Debian installation to a new major version, it can become a true nightmare.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Managing a large set of systems has always been a hassle.
Although tools like Ansible and Puppet simplify the process significantly, it can still be time-consuming when managing tens of systems.</p>
<p>Appliances designed to run specific software often start as a simple Debian installation.
The initial installation and configuration take a few hours, and updates usually need to be performed manually.
While this small investment of time is perfectly fine for a handful of systems, growing to tens of machines requires automating installations and enabling automatic (security) updates.
However, the moment you want—or need—to upgrade the Debian installation to a new major version, it can become a true nightmare.</p>
<p>Luckily, solutions have existed for years.
Instead of starting with a standard Linux installation and configuring it afterward, you can create an image with a preconfigured installation.
This reduces installation time to a few minutes, and updates become nothing more than uploading a new image.
Instead of repeating every installation, configuration, or update step, managing tens of systems is reduced to managing a single image.</p>
<p>This approach has been the standard for Android for years.
SteamOS, Flatcar, and Tesla cars are all based on this concept.
And who doesn&rsquo;t remember the era of Linux live CDs like KNOPPIX?</p>
<p>To support robust updates, a system utilizes two separate partitions: one for the running system image and the other for the new update.
Updates can be downloaded and installed while the system is still running the old image, reducing downtime to a single reboot.</p>
<p>Interested in how easy it would be to set up a system like this, I created a simple template for building a modern, immutable Linux distribution.</p>
<h2 id="read-only-operating-system-roos">Read-Only Operating System (ROOS)</h2>
<p>ROOS is a template using <strong>mkosi</strong> with additional scripts to create a feature-complete, immutable Linux image.
Unlike some other desktop-focused immutable Linux distributions, this template is meant for server appliances; therefore, the <code>/etc</code> directory is read-only by default.
Out of the box, the generated image provides a Linux system that can be accessed using SSH and nothing more.
Additional functionality can be added by enabling profiles during build time.</p>
<h3 id="systemd-and-mkosi">systemd and mkosi</h3>
<p><a href="https://mkosi.systemd.io/">Mkosi</a> is a wrapper around the most commonly used package managers designed to generate customized disk images.
It has been developed as part of the <a href="https://systemd.io">systemd</a> project.
Since systemd was introduced around 15 years ago, it has become the init system for most Linux distributions.</p>
<p>While systemd has been criticized for being very opinionated and deviating from commonly used Unix startup scripts, it provides significant functionality.
It has standardized behavior across distributions and made creating immutable systems much easier.
For example, in recent years, systemd added:</p>
<ul>
<li><a href="https://systemd.io/BOOT/">systemd-boot</a>: An EFI bootloader with boot assessment and counting.</li>
<li><strong>systemd-repart</strong>: A tool to repartition the disk.</li>
<li><strong>systemd-sysupdate</strong>: A tool for handling atomic updates.</li>
</ul>
<p>These are available on every recent Linux distribution.
Therefore, ROOS utilizes a lot of functionality native to systemd to reduce dependencies on other packages.
For more information, read <a href="https://0pointer.net/blog/a-re-introduction-to-mkosi-a-tool-for-generating-os-images.html">Lennart Poettering&rsquo;s blog</a> (creator of systemd and mkosi).</p>
<h3 id="base-image">Base Image</h3>
<p>Mkosi uses <strong>systemd-repart</strong> for creating disk images.
The default configuration for ROOS is provided in <code>mkosi.conf.d/roos-base/mkosi.repart</code>.
It creates a disk image with four partitions.
The two most important are the <strong>ESP partition</strong> for the EFI bootloader and the <strong>root partition</strong>.
The other two are used by <strong>dm-verity</strong> for integrity and validation of the root partition.</p>
<p>The root partition is provided as a read-only image using the <strong>EroFS</strong> file system.
This is a more modern format than SquashFS and is quite popular in Android and containerized environments.
By using zstd compression, the images remain small; however, to keep room for additional applications, the default size of the root partition is set to 4GB.</p>
<p>The additional partitions required for the A/B setup and the <code>/var</code> partition are created on boot by the systemd-repart service.
The configuration is included in <code>mkosi.images/roos-base/mkosi.extra/usr/lib/repart.d</code>.</p>
<p>As an additional security measure, the <code>/var</code> partition is encrypted, and the encryption key is stored in the TPM.
While the TPM is far from perfect at protecting against attackers with physical access, it heightens the bar significantly.
The keys can also be bound to specific <a href="https://systemd.io/TPM2_PCR_MEASUREMENTS/">PCRs</a> to ensure the system is booted into ROOS and into a specific state.
This can detect tampering with the BIOS; however, since there is no standard provision for a recovery key (like BitLocker), the key is currently only linked to the Secure Boot state to prevent data loss.</p>
<p>A profile is provided to enable the A/B partition setup, making it easier to test the image in a VM without requiring large disks.</p>
<p><strong>A Note on Partition Mounting and Machine IDs</strong>
Mounting partitions depends completely on the <a href="https://uapi-group.org/specifications/specs/discoverable_partitions_specification/">UAPI Group Discoverable Partitions Specification</a>.
Partitions are mounted based on their UUIDs.
According to the specifications, the UUID of the <code>/var</code> partition is a hash containing the <code>machine-id</code>.</p>
<p>Normally, the <code>machine-id</code> is generated on first boot and stored in <code>/etc</code>.
Since <code>/etc</code> is immutable on ROOS, this standard approach doesn&rsquo;t work.
Therefore, the <code>systemd.machine_id=firmware</code> kernel option is used to ensure the <code>machine-id</code> is derived from the firmware.
Additionally, the systemd-repart service is altered in the initrd (<code>mkosi.images/initrd</code>) to ensure this machine ID is used as the seed for creating the <code>/var</code> partition.</p>
<p>If no machine ID can be found, the system will use a random value.
This results in a failed boot because the system will generate a random ID when setting up <code>/var</code>, and then generate a <em>new, mismatched</em> ID when trying to mount it.
This issue is easily replicated in QEMU, as emulated machines often lack a firmware UUID by default.
To fix this, the <code>--uuid</code> option must be set (avoid using all zeros, as that is interpreted as unset).
Also, be aware that some cheap NUC-like machines do not have unique machine IDs, as I experienced during development.</p>
<p>The <code>/var</code> partition uses the copy-on-write file system <strong>BTRFS</strong>.
This file system supports hard links and subvolumes.
While not directly used by ROOS basics, this is relevant for running containers or creating snapshots of the system configuration.
BTRFS is also advised for building via mkosi, as it uses hard links to optimize the build process.</p>
<h3 id="unified-kernel-images-ukis">Unified Kernel Images (UKIs)</h3>
<p>With EFI and the systemd EFI stub, it is possible to create EFI executables that pack the initrd, kernel, microcode, and kernel command line into a single file.
These <strong>Unified Kernel Images (UKIs)</strong> are automatically detected by the systemd-boot bootloader, providing a simple method for updates.</p>
<p>When a new version of the root partition image is provided, a new UKI is installed.
The kernel command line inside the UKI contains a reference to the hash of the root partition via the <code>roothash=</code> parameter.
Based on this hash, the system can automatically detect the corresponding root partition.</p>
<p>Systemd-boot also supports <a href="https://systemd.io/AUTOMATIC_BOOT_ASSESSMENT/">automatic boot assessment</a> and uses the UKI filename to count the number of boot attempts.
After booting, systemd marks a UKI as &ldquo;blessed&rdquo; if it boots successfully.
If booting fails, it will automatically revert to the old version after a few attempts.</p>
<p>To support different kernel command lines, a UKI can provide multiple profiles.
ROOS includes two additional profiles shown in the boot menu:</p>
<ol>
<li><strong>Factory Reset:</strong> Cleans the <code>/var</code> partition and creates a new empty one.</li>
<li><strong>Storage Target Mode:</strong> Makes the machine&rsquo;s disks available over NVMe-over-TCP.
Note that there is no authentication for this mode, but since the <code>/var</code> partition is encrypted, it does not automatically grant access to data.
This mode is mainly provided to facilitate image replacement if an update breaks the system.</li>
</ol>
<h3 id="systemd-sysupdate">systemd-sysupdate</h3>
<p>Sysupdate is a component of systemd that downloads and installs disk images and files, such as the root partition and UKIs.
Configuration is stored in <code>/usr/lib/sysupdate.d</code>, and it relies on versions encoded in filenames and partition labels to detect installed versions.</p>
<p>If sysupdate detects that a UKI and a corresponding root partition are present, it marks that version as installed.
Based on the currently running version, the active partition and UKI are protected from deletion.
It will also remove old UKI versions if the corresponding partition needs to be overwritten.
The <code>systemd-sysupdate.timer</code> automatically checks for updates every 8 hours.</p>
<p><strong>Setting up the Update Server</strong>
To provide updates, you only need a simple HTTP server.
Sysupdate downloads the <code>SHA256SUMS</code> files and determines the available version based on the filenames.</p>
<p>Mkosi creates the necessary <code>SHA256SUMS</code> file by default, but prefixes it with the image name and ID.
Mkosi can also start an HTTP server to serve the <code>mkosi.output</code> directory using <code>mkosi serve</code>, but you must rename or symlink the checksum file before sysupdate can use it.
While this file can be signed using GPG, this is disabled for now to simplify the setup, assuming updates are provided over HTTPS.</p>
<p>ROOS allows you to configure the update server as <code>UPDATE_URL</code> in the <code>mkosi.env</code> file.
This file stores simple configuration options, such as the URL and default username.
An example is provided as <code>mkosi.env.example</code>.
When <code>UPDATE_URL</code> is set, the build process creates the necessary update files for the UKI and root partition using templates from <code>roos.resources/sysupdate</code>.</p>
<h3 id="users-and-ssh">Users and SSH</h3>
<p>The default user is configured using the <code>DEFAULT_USER</code> key.
Because only the <code>/var</code> partition is writable, the home directory is stored in <code>/var/home</code> rather than on the root partition.</p>
<p>Since <code>/etc</code> is immutable, the <code>passwd</code> and <code>shadow</code> files are read-only, meaning passwords cannot be changed.
While systemd provides <code>systemd-homed</code> and <code>userdb</code> for dynamic users, a single predefined user is sufficient for ROOS.
As most appliances are managed over the network, no passwords are set; logins depend completely on public keys.</p>
<p>Since the home directory is empty and created only on the first boot, SSH is configured to read authorized keys from <code>/etc/authorized_keys</code>.
You can provide an authorized keys file to ROOS by creating a <code>config/authorized_keys</code> file in your build tree.
Host keys are stored in <code>/var/lib/ssh/etc/ssh/</code> and are generated on boot as normal.</p>
<p><strong>Sudo</strong> is used for privilege escalation and is configured to allow running commands as root without a password.
The <code>DEFAULT_USER</code> is added to the <code>wheel</code> group for this purpose.
Systemd also provides its own replacement called <code>run0</code>, and there is a Rust-based alternative used by Ubuntu, so this may change in the future.</p>
<h3 id="network-configuration">Network Configuration</h3>
<p>Most systems simply depend on DHCP for network configuration.
To support this basic setup, a <strong>systemd-networkd</strong> configuration file is provided to automatically enable DHCP for all wired connections.
Additional or advanced network configuration can be achieved using configuration extensions and credentials (covered in the sections below).</p>
<p>To ensure all machines running the same image have unique hostnames, the hostname is derived from the machine ID by default.
This is similar to embedded systems that contain random-looking characters in their hostnames.
The default hostname is set to <code>roos-???????</code>, where the question marks are replaced with a hashed value of the machine ID.</p>
<h3 id="terminal">Terminal</h3>
<p>Without passwords, logging in from a physical terminal would be trivial and insecure.
Therefore, the login terminal is disabled.
Instead, <strong>fastfetch</strong> is used to display system information—such as the IP address and disk usage—when a monitor is connected.</p>
<p>This can be inconvenient during development and testing.
You can override this behavior by creating a file at <code>/usr/lib/systemd/system-preset/70-enable-login.preset</code> (in the <code>mkosi.extra</code> directory of the main image) with the following content:</p>
<pre tabindex="0"><code>disable fastfetch.service
enable getty@.service
</code></pre><p>This undoes the default preset that disabled getty and enabled fastfetch.</p>
<h2 id="extendibility">Extendibility</h2>
<p>A completely read-only system is often not very usable.
One of the main barriers to adopting immutable images is the requirement to make configuration adjustments per system.
ROOS addresses this through several methods.</p>
<h3 id="custom-images">Custom Images</h3>
<p>The main image configuration can be edited by creating <code>mkosi.conf</code> in the root of the ROOS repository.
Here, you can add packages to the image and configure which profiles should be included.
Additional configuration files can be added to the <code>mkosi.extra</code> directory; the content of this directory is copied into the image after all packages are installed.</p>
<p>By default, all disk images include the ROOS configuration <code>mkosi.conf/roos</code> and files from the <code>mkosi.image/roos-base</code> image.
If you wish to create multiple images at once from a single repository, you can create subdirectories in <code>mkosi.images</code>.</p>
<h3 id="system-and-configuration-extensions">System and Configuration Extensions</h3>
<p>Systemd provides methods to apply additional configuration on top of the immutable image: <strong>systemd-sysext</strong> and <strong>systemd-confext</strong>.</p>
<ul>
<li><strong>sysext:</strong> Provides disk overlays with additional files for <code>/usr</code> and <code>/opt</code>.</li>
<li><strong>confext:</strong> Provides disk overlays for <code>/etc</code>.</li>
</ul>
<p>These images can be created by <code>systemd-repart</code> and are mounted during boot using <strong>overlayfs</strong>.
Since they are mounted during boot, the files are not visible during the very early boot stages.
Services must be explicitly configured to run <em>after</em> <code>systemd-sysext</code> and <code>systemd-confext</code> to ensure they see the additional files.</p>
<p>Every extension contains an <code>extension-release</code> file that is checked to ensure the <code>ID</code>, <code>IMAGE_ID</code>, <code>SYSEXT_LEVEL</code>, or <code>CONFEXT_LEVEL</code> matches the system&rsquo;s values.
This links extensions to specific versions of the main image, allowing them to be updated together.
Examples of a confext and sysext are provided in the <code>extensions/</code> and <code>confexts/</code> folders, including a build script to create signed extensions.</p>
<p>Extensions can be stored in <code>/var/extensions</code> and <code>/var/confexts</code>.
It is also possible to install them on the EFI boot partition or use them to extend the initrd of a UKI.
For more information, see Lennart Poettering&rsquo;s <a href="https://0pointer.net/blog/testing-my-system-code-in-usr-without-modifying-usr.html">blog post about extensions</a>.</p>
<h3 id="credentials">Credentials</h3>
<p>Another configuration method is the usage of <strong>credentials</strong>.
These are encrypted options read by systemd services, decrypted by a key on the <code>/var</code> partition or the TPM.
They can be stored on the <code>/var</code> partition or the EFI boot partition.</p>
<p>Systemd provides default keys that can be configured using the credential system.
For example, you can add systemd-networkd configuration files, VPN configurations, or SSH keys.
Note that for SSH, the configuration is already overridden by ROOS and probably conflicts with options provided by systemd.</p>
<p>For more information, see the <a href="https://systemd.io/CREDENTIALS/">CREDENTIALS page</a> of systemd.</p>
<h3 id="security">Security</h3>
<p>Without going into excessive detail, it is important to note that by default, anyone can create and install extensions and credentials.
While credentials can be encrypted, non-encrypted credentials are also accepted.</p>
<p>Both extensions and credentials depend on <strong>Secure Boot</strong> to fully lock down the system.
While UKIs and images are signed by a user-configured key, you must manually add this key and enable Secure Boot.
Currently, auto-configuring Secure Boot using custom keys is not possible due to the risk of soft-bricking devices.</p>
<p>Furthermore, on many systems, you must include a Microsoft signing key (and a vendor signing key) to load EFI drivers from the BIOS.
Consequently, you end up with a system that both you and Microsoft can access—unless you add the hashes of the EFI drivers manually instead of trusting the generic keys.
However, when Secure Boot is correctly configured, extensions and credentials will only be loaded if they are signed by a trusted key.</p>
<h2 id="usage">Usage</h2>
<p>To create your own image, a <code>README.md</code> is provided in the <a href="https://github.com/irtimmer/roos">ROOS repository</a>.
It contains all the steps required to setup and start building your own custom appliances.</p>
]]></content:encoded></item></channel></rss>