<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://7ji.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://7ji.github.io/" rel="alternate" type="text/html" /><updated>2026-07-29T03:45:20+00:00</updated><id>https://7ji.github.io/feed.xml</id><title type="html">7Ji’s Blog</title><subtitle>Keep It Simple, Stupid.</subtitle><entry><title type="html">Pseudo writability of read-only physical disk for Windows VM on Linux host</title><link href="https://7ji.github.io/reliability/2026/07/28/win-vm-rw-on-linux-ro.html" rel="alternate" type="text/html" title="Pseudo writability of read-only physical disk for Windows VM on Linux host" /><published>2026-07-28T09:30:00+00:00</published><updated>2026-07-28T09:30:00+00:00</updated><id>https://7ji.github.io/reliability/2026/07/28/win-vm-rw-on-linux-ro</id><content type="html" xml:base="https://7ji.github.io/reliability/2026/07/28/win-vm-rw-on-linux-ro.html"><![CDATA[<p>TL;DR: use <a href="https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/snapshot.html">device-mapper snapshot target</a></p>

<p>Recently I’ve decided to set up my 9800X3D + 9070XT gaming PC I assembled more than 1 year ago as a server with GPU passthrough in a dedicated VM for offline “dangerous” gaming on top of the physical Debian host, alongside the physical Windows host that I would only boot into for “legitimate” gaming.</p>

<p>The PC has three drives:</p>
<ul>
  <li>Samsung 990 PRO with heatsink 2TB as physical Windows system drive and for online games and IO-bound offline games</li>
  <li>Geil P4S 4TB as physical Windows data drive and for offline games</li>
  <li>HYVX4 2TB as physical Debian system drive</li>
</ul>

<p>As I sometimes would prefer play games from my collection from the physical Windows drives directly in the VM than rebooting into the physical Windows, especially when there’s workload running on the Debian system, I prefer if the physical Windows drives are readable in the VM.</p>

<p>The simplest idea to pass through both of these disks surely have performance benefits, but if viruses and trojans went wild in the VM, while the system residing on QCOW2 could be rolled back, the physcial drives passed through then cannot be fixed, things just go boom. Besides, I don’t want the physical Windows drives accidentally modified even not under those circumstances, so writable physical is no-go.</p>

<p>The next simplest idea is to make the drives read-only, on the block device level under Linux. This is quite easy to do with a simple udev rules file:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">cat</span> /etc/udev/rules.d/99-windows-ro.rules
<span class="nv">SUBSYSTEM</span><span class="o">==</span><span class="s2">"block"</span>, <span class="nv">ACTION</span><span class="o">==</span><span class="s2">"add"</span>, ENV<span class="o">{</span>DEVTYPE<span class="o">}==</span><span class="s2">"disk"</span>, ENV<span class="o">{</span>ID_SERIAL<span class="o">}==</span><span class="s2">"Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX_1|GeIL_P4S_4TB_XXXXXXXXXXX_1"</span>, RUN+<span class="o">=</span><span class="s2">"/sbin/blockdev --setro /dev/%k"</span>
</code></pre></div></div>

<p>The character <code class="language-plaintext highlighter-rouge">|</code> is used to match multiple drives, and each IDs are obtained through simple <code class="language-plaintext highlighter-rouge">udevadm info</code>:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">sudo </span>udevadm info /dev/disk/by-id/nvme-Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX
......
E: <span class="nv">ID_SERIAL</span><span class="o">=</span>Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX_1
......
</code></pre></div></div>

<p>Note a explicit command with <code class="language-plaintext highlighter-rouge">RUN+=</code> is used instead of <code class="language-plaintext highlighter-rouge">ATTR{ro}="1"</code>, as the latter file is simply read-only, and writing 0 to there does not work.</p>

<p>These rules put the drive into read-only state once after boot (albeit revertable through explicit commands). The read-only state could be verified from the <code class="language-plaintext highlighter-rouge">ro</code> sysfs virtual file:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">ls</span> <span class="nt">-l</span> /dev/disk/by-id/nvme-<span class="o">{</span>HYV2TBX4_2280__XXXXXXXXXXXXXXX,GeIL_P4S_4TB_XXXXXXXXXXX,Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX<span class="o">}</span>
lrwxrwxrwx 1 root root 13 Jul 28 17:17 /dev/disk/by-id/nvme-GeIL_P4S_4TB_XXXXXXXXXXX -&gt; ../../nvme1n1
lrwxrwxrwx 1 root root 13 Jul 28 17:17 /dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXX -&gt; ../../nvme0n1
lrwxrwxrwx 1 root root 13 Jul 28 17:17 /dev/disk/by-id/nvme-Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX -&gt; ../../nvme2n1
</code></pre></div></div>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">cat</span> /sys/block/nvme<span class="k">*</span>n1/ro
0
1
1
</code></pre></div></div>

<p>One can write to these drives and confirm all writes would fail.</p>

<p>The read-only drives then can be assigned to the VM without worrying about they getting written, either by writing the path explicitly in virt-manager GUI, or with a disk node similar to the following one in XML / virsh edit:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;disk</span> <span class="na">type=</span><span class="s">'block'</span> <span class="na">device=</span><span class="s">'disk'</span><span class="nt">&gt;</span>
    <span class="nt">&lt;driver</span> <span class="na">name=</span><span class="s">'qemu'</span> <span class="na">type=</span><span class="s">'raw'</span> <span class="na">cache=</span><span class="s">'none'</span> <span class="na">io=</span><span class="s">'native'</span> <span class="na">discard=</span><span class="s">'unmap'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;source</span> <span class="na">dev=</span><span class="s">'/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;target</span> <span class="na">dev=</span><span class="s">'vdc'</span> <span class="na">bus=</span><span class="s">'virtio'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;readonly/&gt;</span>
    <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x0f'</span> <span class="na">slot=</span><span class="s">'0x00'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
<span class="nt">&lt;/disk&gt;</span>
</code></pre></div></div>

<p>Note that the readonly state must be marked explicitly (also could through virt-manager GUI), otherwise the VM manager could not open the corresponding block device. The VM itself and the system running inside therefore knows the fact that the drive is read-only.</p>

<p>While Windows could mount such read-only drive, and content could be indexed and read, many games don’t like their executable residing in a read-only folder. If one would want to play such game from the read-only drive, the only possible workaround is to copy the whole thing into the writable VM drive.</p>

<p>If only we could fake the writability of such read-only drive and make the VM believe they can write something onto it. And it’s natural to come up the idea to lay some writable block layer on top of such read-only block, to get a writable block.</p>

<p>Luckily the device-mapper system in Linux is quite powerful and provides many useful “target”s, among them the <a href="https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/snapshot.html">dm-snapshot</a> provides just what we need. The idea is pretty similay to overlayfs albeit on the block device level: it combines a optionally read-only low layer, a optionally persistent copy-on-write layer, to get a read-write block device, that starts as a merely “clone” of the low layer, and writes would only hit the CoW layer, not the bottm layer.</p>

<p>To assemble such dm, a command looks like the following:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>dmsetup create winData <span class="nt">--table</span> <span class="s1">'0 8001573552 snapshot /dev/disk/by-id/nvme-GeIL_P4S_4TB_XXXXXXXXXXX /dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXXX-part5 P 128'</span>
</code></pre></div></div>

<p>The arguments might look frightening and hard to understand at the first glance, but it’s pretty structural:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">dmsetup create</code> to create a dm</li>
  <li><code class="language-plaintext highlighter-rouge">winData</code> is the name of the to-be-created dm, a symlink <code class="language-plaintext highlighter-rouge">/dev/mapper/winData</code> would be created to point to the actual numbered device (usually dm0)</li>
  <li><code class="language-plaintext highlighter-rouge">--table</code> begins the dm table declaration as a single long string as the following argumnent, and in it:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">0</code> marks the start sector of first target</li>
      <li><code class="language-plaintext highlighter-rouge">8001573552</code> marks the end sector of first target; the two combined means the assembled dm block device would have its <code class="language-plaintext highlighter-rouge">0</code> to <code class="language-plaintext highlighter-rouge">8001573552 - 1</code> sector “mapped” to the currrently declared target, with details coming next to it; so the size is also <code class="language-plaintext highlighter-rouge">8001573552</code> (end-begin), which could be obtained by <code class="language-plaintext highlighter-rouge">cat /sys/block/nvmeXn1/size</code></li>
      <li><code class="language-plaintext highlighter-rouge">snapshot</code> sets the current dm target to <code class="language-plaintext highlighter-rouge">dm-snapshot</code>, and later table arguments should define its target-specific setting</li>
      <li><code class="language-plaintext highlighter-rouge">/dev/disk/by-id/nvme-GeIL_P4S_4TB_XXXXXXXXXXX</code> is the bottom layer, no write would hit it; in this case it’s the 4TB physical drive</li>
      <li><code class="language-plaintext highlighter-rouge">/dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXXX-part5</code> is the Copy-on-Write layer, all writes to the assembled dm would only hit this layer, with the whole chunk copied to there first before being modified; in this case it’s a 128GB physical partition, but it could also be a LVM LV, a loopback file, a RAM disk, or any block you can spare</li>
      <li><code class="language-plaintext highlighter-rouge">P</code> tells the CoW layer is persistent; i.e. on re-assembling after physical reboot, the assembled dm should still look the same on binary perspective; I chose the data drive as persistent as maybe sometimes I would store games here rather than the VM system drive, and I don’t want it gone away right after a physical reboot; if the CoW layer shall be dropped, it could easily be done by <code class="language-plaintext highlighter-rouge">blkdiscard -f</code> on the CoW layer without the dm assembled.</li>
      <li><code class="language-plaintext highlighter-rouge">128</code> is the count of sectors as chunk size, with a 512 sector size this means <code class="language-plaintext highlighter-rouge">512 byte/sector * 128 sector = 65536 byte = 64 KiB</code> chunk size, which should be a friendly IO size for SSD, and reduces the metadata; the dm-snapshot documentation wrote 16 in its example for lvm snapshot, so you can choose freely</li>
    </ul>
  </li>
</ul>

<p>For a non-persistent case, the command is similar except the persistent mark is <code class="language-plaintext highlighter-rouge">N</code></p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>dmsetup create winSystem <span class="nt">--table</span> <span class="s1">'0 3907029168 snapshot /dev/disk/by-id/nvme-Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX /dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXXX-part4 N 128'</span>
</code></pre></div></div>

<p>After a full assembling, the <code class="language-plaintext highlighter-rouge">lsblk</code> output would look like the following:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME          MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
nvme2n1       259:0    0  1.8T  1 disk
├─winSystem   254:0    0  1.8T  0 dm
├─nvme2n1p1   259:1    0  100M  1 part
└─nvme2n1p2   259:2    0  1.8T  1 part
nvme1n1       259:3    0  3.7T  1 disk
├─winData     254:1    0  3.7T  0 dm
├─nvme1n1p1   259:4    0   16M  1 part
└─nvme1n1p2   259:5    0  3.7T  1 part
nvme0n1       259:6    0  1.9T  0 disk
├─......
├─nvme0n1p4   259:10   0   32G  0 part
│ └─winSystem 254:0    0  1.8T  0 dm
├─nvme0n1p5   259:11   0  128G  0 part
│ └─winData   254:1    0  3.7T  0 dm
└─......
</code></pre></div></div>

<p>Note the assmebled block device does not have their partitton identified, which is by design, and for a VM access this is pretty OK. If you want the partitions however, this can be fixed with a manual <code class="language-plaintext highlighter-rouge">kpartx</code> call, e.g.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>kpartx <span class="nt">-a</span> /dev/mapper/winSystem
</code></pre></div></div>

<p>Then the <code class="language-plaintext highlighter-rouge">lsblk</code> output would look like the following:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME             MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
nvme2n1          259:0    0  1.8T  1 disk
├─winSystem      254:0    0  1.8T  0 dm
│ ├─winSystem1   254:2    0  100M  0 part
│ └─winSystem2   254:3    0  1.8T  0 part
├─nvme2n1p1      259:1    0  100M  1 part
└─nvme2n1p2      259:2    0  1.8T  1 part
nvme1n1          259:3    0  3.7T  1 disk
├─winData        254:1    0  3.7T  0 dm
├─nvme1n1p1      259:4    0   16M  1 part
└─nvme1n1p2      259:5    0  3.7T  1 part
nvme0n1          259:6    0  1.9T  0 disk
├─...
├─nvme0n1p4      259:10   0   32G  0 part
│ └─winSystem    254:0    0  1.8T  0 dm
│   ├─winSystem1 254:2    0  100M  0 part
│   └─winSystem2 254:3    0  1.8T  0 part
├─nvme0n1p5      259:11   0  128G  0 part
│ └─winData      254:1    0  3.7T  0 dm
└─...
</code></pre></div></div>

<p>In this case, the writability of the dm-snapshot can be verified under Linux by simply mounting the NTFS partition with ntfs-3g, and writting something into it. And after umounting it you could mount the lower read-only NTFS partition to confirm no write hit it.</p>

<p>The VM can then use such pseudo writable drive, e.g.</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;disk</span> <span class="na">type=</span><span class="s">'block'</span> <span class="na">device=</span><span class="s">'disk'</span><span class="nt">&gt;</span>
    <span class="nt">&lt;driver</span> <span class="na">name=</span><span class="s">'qemu'</span> <span class="na">type=</span><span class="s">'raw'</span> <span class="na">cache=</span><span class="s">'none'</span> <span class="na">io=</span><span class="s">'native'</span> <span class="na">discard=</span><span class="s">'unmap'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;source</span> <span class="na">dev=</span><span class="s">'/dev/mapper/winSystem'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;target</span> <span class="na">dev=</span><span class="s">'vdc'</span> <span class="na">bus=</span><span class="s">'virtio'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x0e'</span> <span class="na">slot=</span><span class="s">'0x00'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
<span class="nt">&lt;/disk&gt;</span>
<span class="nt">&lt;disk</span> <span class="na">type=</span><span class="s">'block'</span> <span class="na">device=</span><span class="s">'disk'</span><span class="nt">&gt;</span>
    <span class="nt">&lt;driver</span> <span class="na">name=</span><span class="s">'qemu'</span> <span class="na">type=</span><span class="s">'raw'</span> <span class="na">cache=</span><span class="s">'none'</span> <span class="na">io=</span><span class="s">'native'</span> <span class="na">discard=</span><span class="s">'unmap'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;source</span> <span class="na">dev=</span><span class="s">'/dev/mapper/winData'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;target</span> <span class="na">dev=</span><span class="s">'vdd'</span> <span class="na">bus=</span><span class="s">'virtio'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x0f'</span> <span class="na">slot=</span><span class="s">'0x00'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
<span class="nt">&lt;/disk&gt;</span>
</code></pre></div></div>

<p>Some systemd service units could also be used if the dm-snapshot blocks shall be ready upon boot:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">cat</span> /etc/systemd/system/dmsetup-winSystem.service
<span class="o">[</span>Unit]
<span class="nv">Description</span><span class="o">=</span>Setup winSystem device with dmsetup
<span class="nv">Requires</span><span class="o">=</span>dev-disk-by<span class="se">\x</span>2did-nvme<span class="se">\x</span>2dSamsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX.device
<span class="nv">After</span><span class="o">=</span>dev-disk-by<span class="se">\x</span>2did-nvme<span class="se">\x</span>2dSamsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX.device

<span class="o">[</span>Service]
<span class="nv">Type</span><span class="o">=</span>oneshot
<span class="nv">RemainAfterExit</span><span class="o">=</span><span class="nb">yes
</span><span class="nv">ExecStart</span><span class="o">=</span>/usr/sbin/dmsetup create winSystem <span class="nt">--table</span> <span class="s1">'0 3907029168 snapshot /dev/disk/by-id/nvme-Samsung_SSD_990_PRO_with_Heatsink_2TB_XXXXXXXXXXXXXXX /dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXXX-part4 N 128'</span>

<span class="o">[</span>Install]
<span class="nv">WantedBy</span><span class="o">=</span>local-fs.target
</code></pre></div></div>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">cat</span> /etc/systemd/system/dmsetup-winData.service
<span class="o">[</span>Unit]
<span class="nv">Description</span><span class="o">=</span>Setup winData device with dmsetup
<span class="nv">Requires</span><span class="o">=</span>dev-disk-by<span class="se">\x</span>2did-nvme<span class="se">\x</span>2dGeIL_P4S_4TB_XXXXXXXXXXX.device
<span class="nv">After</span><span class="o">=</span>dev-disk-by<span class="se">\x</span>2did-nvme<span class="se">\x</span>2dGeIL_P4S_4TB_XXXXXXXXXXX.device

<span class="o">[</span>Service]
<span class="nv">Type</span><span class="o">=</span>oneshot
<span class="nv">RemainAfterExit</span><span class="o">=</span><span class="nb">yes
</span><span class="nv">ExecStart</span><span class="o">=</span>/usr/sbin/dmsetup create winData <span class="nt">--table</span> <span class="s1">'0 8001573552 snapshot /dev/disk/by-id/nvme-GeIL_P4S_4TB_XXXXXXXXXXX /dev/disk/by-id/nvme-HYV2TBX4_2280__XXXXXXXXXXXXXXXX-part5 P 128'</span>

<span class="o">[</span>Install]
<span class="nv">WantedBy</span><span class="o">=</span>local-fs.target
</code></pre></div></div>

<p>The Windows physical drives therefore, while read-only, appears writable in the Windwos VM, and upon physical Debian host reboot, changes made into the physical Windows system snapshot are gone, but made into the physical Windows data snapshot still persist but could also be throw away with <code class="language-plaintext highlighter-rouge">blkdiscard</code>, both physical drives always unmodified.</p>]]></content><author><name></name></author><category term="reliability" /><summary type="html"><![CDATA[TL;DR: use device-mapper snapshot target]]></summary></entry><entry><title type="html">Into Alpine APK v3 format: the binary perspective</title><link href="https://7ji.github.io/designdoc/2026/03/03/into-Alpine-APK-v3-format-the-binary-perspective.html" rel="alternate" type="text/html" title="Into Alpine APK v3 format: the binary perspective" /><published>2026-03-03T03:30:00+00:00</published><updated>2026-03-03T03:30:00+00:00</updated><id>https://7ji.github.io/designdoc/2026/03/03/into-Alpine-APK-v3-format-the-binary-perspective</id><content type="html" xml:base="https://7ji.github.io/designdoc/2026/03/03/into-Alpine-APK-v3-format-the-binary-perspective.html"><![CDATA[<h2 id="background">Background</h2>

<p>From OpenWrt 25.12 onwards, and available earlier in release candidates and development branches, OpenWrt has swapped their package manager + package format from <code class="language-plaintext highlighter-rouge">opkg</code> + <code class="language-plaintext highlighter-rouge">.ipk</code> (almost a <code class="language-plaintext highlighter-rouge">.deb</code>) to  <code class="language-plaintext highlighter-rouge">apk-tools v3.0</code> from Alpine Linux and its newly introduced <code class="language-plaintext highlighter-rouge">APK v3</code>. Even Alpine itself has just upgraded <code class="language-plaintext highlighter-rouge">apk-tools</code> to <code class="language-plaintext highlighter-rouge">v3.0</code>  since <code class="language-plaintext highlighter-rouge">v3.23</code> but not fully switched into <code class="language-plaintext highlighter-rouge">apk v3</code> yet.</p>

<p>The format is a totally new format designed in-house by Alpine with some collabration from OpenWrt. But let’s first list the above mentioned older formats:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">deb</code> is an <code class="language-plaintext highlighter-rouge">ar</code> like format with pre-defined members <code class="language-plaintext highlighter-rouge">debian-binary</code>, <code class="language-plaintext highlighter-rouge">data.tar(.gz/.xxx)</code>, <code class="language-plaintext highlighter-rouge">control.tar(.gz/.xxx)</code>, the files are in <code class="language-plaintext highlighter-rouge">data.tar.xxx</code> and metadata is in <code class="language-plaintext highlighter-rouge">control.tar.xxx</code></li>
  <li><code class="language-plaintext highlighter-rouge">ipk</code> is pretty much a fixed version <code class="language-plaintext highlighter-rouge">deb</code> with in most cases only gzip compressed <code class="language-plaintext highlighter-rouge">data</code> and <code class="language-plaintext highlighter-rouge">control</code> so less dependency is needed which is useful in embedded scenarios.</li>
  <li><code class="language-plaintext highlighter-rouge">apk</code> v2 is three (with signature) or two gzipped (without signature) <code class="language-plaintext highlighter-rouge">tar</code>s, one optionally for signature, one for metadata (kinda like <code class="language-plaintext highlighter-rouge">control.tar</code> in <code class="language-plaintext highlighter-rouge">deb</code>) and one for actual files (kinda like <code class="language-plaintext highlighter-rouge">data.tar</code> in deb)</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">apk</code> v3 however is in a supposedly schema-based binary format <code class="language-plaintext highlighter-rouge">adb</code>, loosely defined by its official manual (<a href="https://github.com/alpinelinux/apk-tools/blob/master/doc/apk-v3.5.scd">source</a> / <a href="https://man.archlinux.org/man/extra/apk-tools/apk-v3.5.en">online</a>), yet most details are determined by <code class="language-plaintext highlighter-rouge">apk-tools</code> the only reference tool’s C source code.</p>

<p>I’ve recently wrote a tool <a href="https://github.com/7Ji/adumpk/">adumpk</a> to parse an v3 <code class="language-plaintext highlighter-rouge">.apk</code>, prints useful info about it, optionally convert it to <code class="language-plaintext highlighter-rouge">.tar</code>, and write metainfo into <code class="language-plaintext highlighter-rouge">.json</code>. When writing the tool I had to jump around in <code class="language-plaintext highlighter-rouge">apk-tools</code>’s source code quite often. After finishing adumpk, I decided to write this as an easier-to-follow single blog post so others can avoid the hassle.</p>

<h2 id="format">Format</h2>

<p>Unless explicitly mentioned, all integral data types in the format is little-endian.</p>

<p>The below uses <a href="http://downloads.openwrt.org/releases/25.12.0-rc5/packages/x86_64/packages/crowdsec-1.6.2-r1.apk">openwrt/25.12.0-rc5/crowdsec-1.6.2-r1.apk</a> as an example package, although it’s not needed, it’s recommended you get one so it’s easier to examine the binary by yourself and learn the actual binary format.</p>

<h3 id="file-header">File Header</h3>

<p>The file header is 4 bytes in length, first 3 bytes being magic <code class="language-plaintext highlighter-rouge">"ADB"</code>, i.e. big-endian <code class="language-plaintext highlighter-rouge">0x414442</code>, then the last byte is either one of the following to tell the file compression method:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">.</code>, i.e. <code class="language-plaintext highlighter-rouge">0x2e</code>, means the content is not compressed</li>
  <li><code class="language-plaintext highlighter-rouge">d</code>, i.e. <code class="language-plaintext highlighter-rouge">0x64</code>, means the conetnt is compressed with Deflate level 0</li>
  <li><code class="language-plaintext highlighter-rouge">c</code>, i.e. <code class="language-plaintext highlighter-rouge">0x63</code>, means the content is compressed with a custom compression method recorded in the next 2 bytes
    <ul>
      <li>the first byte is a <code class="language-plaintext highlighter-rouge">u8</code> recording the ID of compression method:
        <ul>
          <li>0 for not compressed</li>
          <li>1 for Deflate</li>
          <li>2 for Zstandard, which is optionally supported only when <code class="language-plaintext highlighter-rouge">apk-tools</code> was compiled with <code class="language-plaintext highlighter-rouge">HAVE_ZSTD</code></li>
        </ul>
      </li>
      <li>the second byte is a <code class="language-plaintext highlighter-rouge">u8</code> containing compression level, the level allowed by each of the above method is:
        <ul>
          <li>not compressed: 0</li>
          <li>Deflate: 0 to 9</li>
          <li>Zstandard: 0 to 22</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<h3 id="compressed-body">Compressed Body</h3>

<p>The compressed <code class="language-plaintext highlighter-rouge">ADB</code> data body either starts from the 7th byte (<code class="language-plaintext highlighter-rouge">file[6:]</code>) if the compression method is <code class="language-plaintext highlighter-rouge">c</code> and 2 addtional bytes were took to record the actual compression method, or the 5th byte (<code class="language-plaintext highlighter-rouge">file[4:]</code>) otherwise.</p>

<p>The compressed <code class="language-plaintext highlighter-rouge">ADB</code> data body shall be <strong>a plain stream without magic header</strong> you would expect from a standalone compressed file, so the gzip stream does not begin with big-endian <code class="language-plaintext highlighter-rouge">0x1F8B</code> magic then meta, and zstd stream does not begin with big-endian <code class="language-plaintext highlighter-rouge">0x28B52FFD</code> magic.</p>

<p>Note while Deflate stream can be concatenated to each other, which is used in <code class="language-plaintext highlighter-rouge">apk v2</code>, the <code class="language-plaintext highlighter-rouge">apk v3</code> body should be a whole continous Deflate stream if it was compressed with such algorithm. <em>I didn’t test this but I think the official tool would happily accept a manually prepared <code class="language-plaintext highlighter-rouge">apk v3</code> with multiple concatenated Deflate stream</em>. <strong>Zstandard on the other hand does not support concatenated streams.</strong></p>

<p>The body, when decompressed, shall begin with exactly <code class="language-plaintext highlighter-rouge">ADB.</code>, same as uncompressed <code class="language-plaintext highlighter-rouge">apk v3</code>, 3-byte magic and 1-byte marking no compression.</p>

<p>With the example apk the body shall be decompressed with:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">zlib</span>
<span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s">'crowdsec-1.6.2-r1.apk'</span><span class="p">,</span> <span class="s">'rb'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="k">assert</span><span class="p">(</span><span class="n">f</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="mi">4</span><span class="p">)</span> <span class="o">==</span> <span class="sa">b</span><span class="s">'ADBd'</span><span class="p">)</span>
    <span class="n">body</span> <span class="o">=</span> <span class="n">zlib</span><span class="p">.</span><span class="n">decompress</span><span class="p">(</span><span class="n">f</span><span class="p">.</span><span class="n">read</span><span class="p">(),</span> <span class="n">wbits</span><span class="o">=-</span><span class="n">zlib</span><span class="p">.</span><span class="n">MAX_WBITS</span><span class="p">)</span>
<span class="k">assert</span><span class="p">(</span><span class="n">body</span><span class="p">[</span><span class="mi">0</span><span class="p">:</span><span class="mi">8</span><span class="p">]</span> <span class="o">==</span> <span class="sa">b</span><span class="s">'ADB.pckg'</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="top-level-schema">Top-level schema</h3>

<p>After the leading <code class="language-plaintext highlighter-rouge">ADB.</code> in either the original uncompressed file or the decompresssed body, a 4-byte (<code class="language-plaintext highlighter-rouge">u32(body[4:8])</code>) magic (called <code class="language-plaintext highlighter-rouge">schema</code>) would mark the inner data as one of the following:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">0x676B6370</code> or big-endian <code class="language-plaintext highlighter-rouge">0x70636B67</code> or literal <code class="language-plaintext highlighter-rouge">pckg</code> for package</li>
  <li><code class="language-plaintext highlighter-rouge">0x78646E69</code> or big-endian <code class="language-plaintext highlighter-rouge">0x696E6478</code> or literal <code class="language-plaintext highlighter-rouge">indx</code> for index</li>
</ul>

<p>We would only talk about package.</p>

<h3 id="adb-blocks-stream">ADB blocks stream</h3>

<p>In the package case, a series of <code class="language-plaintext highlighter-rouge">ADB block</code>s would continue one after another, each starting at a 8-byte boundary (the first <code class="language-plaintext highlighter-rouge">ADB block</code> naturally starts at such boundary as it’s after 8-byte <code class="language-plaintext highlighter-rouge">"ADB.pckg"</code>).</p>

<p>Each block starts with a <code class="language-plaintext highlighter-rouge">u32</code> recording the type and optionally the size, itself as either a simple header, or as the first member of a bigger 16-byte header</p>

<ul>
  <li>If the highest 2 bits are not all <code class="language-plaintext highlighter-rouge">1</code> (so either <code class="language-plaintext highlighter-rouge">0b00</code>, <code class="language-plaintext highlighter-rouge">0b01</code>, <code class="language-plaintext highlighter-rouge">0b10</code>, but not <code class="language-plaintext highlighter-rouge">0b11</code>), then this is a simple 4-byte header
    <ul>
      <li>Type is these 2 bits extracted, i.e. <code class="language-plaintext highlighter-rouge">v &gt;&gt; 30</code></li>
      <li>Raw size for this block (4-byte header included) is the low 30 bits, i.e. <code class="language-plaintext highlighter-rouge">v &amp; 0x3fffffff</code></li>
    </ul>
  </li>
  <li>If the highest 2 bits are all <code class="language-plaintext highlighter-rouge">1</code>, then this is an extended 16-byte header, including the 4-byte u32 <code class="language-plaintext highlighter-rouge">v</code> itself as <code class="language-plaintext highlighter-rouge">type_size</code> field, a 4-byte u32 reserved field for alignment for future expansion, and a 8-byte u64 <code class="language-plaintext highlighter-rouge">x_size</code> field, defined in C as:
    <div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">adb_block</span> <span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">type_size</span><span class="p">;</span>
    <span class="kt">uint32_t</span> <span class="n">reserved</span><span class="p">;</span>
    <span class="kt">uint64_t</span> <span class="n">x_size</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>    </div>
    <ul>
      <li>Type is the low 30 bits, <code class="language-plaintext highlighter-rouge">i.e. u32 &amp; 0x3fffffff</code></li>
      <li>Raw size for this block (16-byte header included) is the <code class="language-plaintext highlighter-rouge">x_size</code> field</li>
    </ul>
  </li>
</ul>

<p>The payload size of what follows the header can be calculated as <code class="language-plaintext highlighter-rouge">raw size - header size</code>, and it must be non-negative.</p>

<p>The actual type of a block must be one of the following:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>Usage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>ADB_BLOCK_ADB</td>
      <td>essential, contains metadata info and file infos</td>
    </tr>
    <tr>
      <td>1</td>
      <td>ADB_BLOCK_SIG</td>
      <td>optional, carries signature data</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ADB_BLOCK_DATA</td>
      <td>technically also optional, carries file content (no name or path) or alike</td>
    </tr>
  </tbody>
</table>

<p>The order of these blocks is restricted. A sane <code class="language-plaintext highlighter-rouge">ADB</code> must contains these blocks head to tail:</p>

<ul>
  <li>1 <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> for metadata</li>
  <li>0 to N <code class="language-plaintext highlighter-rouge">ADB_BLOCK_SIG</code> for signature</li>
  <li>0 to N <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code> for data</li>
</ul>

<p>If the order is not respected (e.g. <code class="language-plaintext highlighter-rouge">SIG</code> after <code class="language-plaintext highlighter-rouge">DATA</code>, or <code class="language-plaintext highlighter-rouge">ADB</code> after <code class="language-plaintext highlighter-rouge">SIG</code>/<code class="language-plaintext highlighter-rouge">DATA</code>, etc ), then the file would be rejected</p>

<h3 id="the-meta-block-adb_block_adb">The meta block <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code></h3>

<p>Such block must be the first block in one package’s blocks stream.</p>

<p>The block begins with a 8-byte header, including a u8 <code class="language-plaintext highlighter-rouge">adb_compat_ver</code> field (currently must be <code class="language-plaintext highlighter-rouge">0</code>), a u8 <code class="language-plaintext highlighter-rouge">adb_ver</code> field (currently also must be <code class="language-plaintext highlighter-rouge">0</code>), a u16 <code class="language-plaintext highlighter-rouge">reserved</code> field for alignment and future expansion (all 0), then a u32 <code class="language-plaintext highlighter-rouge">root</code> field to declare the following data streams, type aliased as <code class="language-plaintext highlighter-rouge">adb_val_t</code>, defined in C as:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">adb_hdr</span> <span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">adb_compat_ver</span><span class="p">;</span>
    <span class="kt">uint8_t</span> <span class="n">adb_ver</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">reserved</span><span class="p">;</span>
    <span class="n">adb_val_t</span> <span class="n">root</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">adb_val_t</code> <code class="language-plaintext highlighter-rouge">val</code> carries type info in its highest 4 bits (<code class="language-plaintext highlighter-rouge">val &amp; 0xf0000000</code>), and value in lowest 28 bits (<code class="language-plaintext highlighter-rouge">val &amp; 0x0fffffff</code>)</p>

<p>Let’s focus on what <code class="language-plaintext highlighter-rouge">root</code> means here.</p>

<p>The type shall be <code class="language-plaintext highlighter-rouge">ADB_TYPE_OBJECT</code> (<code class="language-plaintext highlighter-rouge">0xe0000000 == root &amp; 0xf0000000</code>) for a series of elements each with their own types, which makes sense for a metadata block.</p>

<p>And the value, i.e. <code class="language-plaintext highlighter-rouge">root &amp; 0x0fffffff</code>, marks the offset of targets <strong>inside current payload</strong>, note the current payload includes the above <code class="language-plaintext highlighter-rouge">adb_hdr</code> but not the block header, so e.g. with <code class="language-plaintext highlighter-rouge">u32([body[8:12]]) == 0x1614 == 5652</code> for a simple 4-byte header, type <code class="language-plaintext highlighter-rouge">ADB</code>, this means the whole block is at <code class="language-plaintext highlighter-rouge">body[8:8+5652] == body[8:5660]</code>, so payload is <code class="language-plaintext highlighter-rouge">body[8+4:5660] == body[12:12+5648]</code> without the block header, including the <code class="language-plaintext highlighter-rouge">adb_hdr</code> at <code class="language-plaintext highlighter-rouge">body[12:12+8] == body[12:20]</code>, so with <code class="language-plaintext highlighter-rouge">root==0xe0001600</code> the offset would be <code class="language-plaintext highlighter-rouge">0x1600 == 5632</code>, and therefore we need to go from <code class="language-plaintext highlighter-rouge">body[12+5632]</code>.</p>

<h3 id="adb-object-for-root">ADB Object for root</h3>

<p>The pointed-to <code class="language-plaintext highlighter-rouge">ADB</code> object elements for root starts with a u32 recording how many <code class="language-plaintext highlighter-rouge">adb_val_t</code>s follow it <strong>including itself</strong>, then the actual series of <code class="language-plaintext highlighter-rouge">adb_val_t</code>s, so e.g. with the above example and <code class="language-plaintext highlighter-rouge">u32(body[5644:5648])</code> being 4, then the 3 <code class="language-plaintext highlighter-rouge">adb_val_t</code>s are expected at <code class="language-plaintext highlighter-rouge">body[5648:5652]</code>, <code class="language-plaintext highlighter-rouge">body[5652:5656]</code>, <code class="language-plaintext highlighter-rouge">body[5656:5660]</code>.</p>

<p><em>Note the last <code class="language-plaintext highlighter-rouge">adb_val_t</code> is just at the end of this payload / ADB_BLOCK_ADB, you can easily tell that the <code class="language-plaintext highlighter-rouge">root</code> value was written at the end of <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> creation</em></p>

<p>The above 3 objects with ID starting at 1, alongside count u32 (<code class="language-plaintext highlighter-rouge">4</code>) with ID 0 (remember this convention, APK prefers to use id 0 for num/count and 1 onwards as actual slots), can be considered a 4-length <code class="language-plaintext highlighter-rouge">adb_val_t</code> array, each latter member is used for a different purpose:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>ADBI_PKG_PKGINFO</td>
      <td>package info metadata</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ADBI_PKG_PATHS</td>
      <td>package file / folder paths (compacted than plain list of texts)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>ADBI_PKG_SCRIPTS</td>
      <td>package postrm / preinst / etc scripts</td>
    </tr>
    <tr>
      <td>4</td>
      <td>ADBI_PKG_TRIGGERS</td>
      <td>package triggers</td>
    </tr>
  </tbody>
</table>

<p>If a slot is not needed then it can be 0 if there’s still slot needed after it, or not exist at all if there’s no slot needed after it. In this example with count being 4 there would only be 3 slots, so no <code class="language-plaintext highlighter-rouge">TRIGGERS</code> was stored.</p>

<p>We would only focus on <code class="language-plaintext highlighter-rouge">PKGINFO</code> and <code class="language-plaintext highlighter-rouge">PATHS</code>, as <code class="language-plaintext highlighter-rouge">SCRIPTS</code> and <code class="language-plaintext highlighter-rouge">TRIGGERS</code> pretty much follows the idea of <code class="language-plaintext highlighter-rouge">PATHS</code> + <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code></p>

<h3 id="adbi_pkg_pkginfo-package-info">ADBI_PKG_PKGINFO: Package Info</h3>

<p>ID 1 in root object, which marks the head of pacakge info, is yet another object, e.g. <code class="language-plaintext highlighter-rouge">root_obj[1] == body[5648:5652] ==  0xe00012b8</code>, means an object (<code class="language-plaintext highlighter-rouge">0xe00012b8 &amp; 0xf0000000 == 0xe0000000</code>) with offset 4792 (<code class="language-plaintext highlighter-rouge">0xe00012b8 &amp; 0x0fffffff == 0x12b8 == 4792</code>), which then points to another <code class="language-plaintext highlighter-rouge">u32</code> for number for elements including itself. So e.g. <code class="language-plaintext highlighter-rouge">u32(body[12+4792:+4]) == u32(body[4804:4808]) == 17</code> means there’re 16 <code class="language-plaintext highlighter-rouge">adb_val_t</code> after the count u32, 1st at <code class="language-plaintext highlighter-rouge">body[4804+4*1:+4] == body[4808:4812]</code> and 16th at <code class="language-plaintext highlighter-rouge">body[4804+4*16:+4] == body[4868:4872]</code>.</p>

<p>Now is a good time to list all possible data types, which can all be possibly used in these fields:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Magic</th>
      <th>Note</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ADB_TYPE_SPECIAL</td>
      <td>0x00000000</td>
      <td>Currently just alias to <code class="language-plaintext highlighter-rouge">INT</code></td>
    </tr>
    <tr>
      <td>ADB_TYPE_INT</td>
      <td>0x10000000</td>
      <td>Single u32 (max 0x0fffffff) value in low</td>
    </tr>
    <tr>
      <td>ADB_TYPE_INT_32</td>
      <td>0x20000000</td>
      <td>Single u32 at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_INT_64</td>
      <td>0x30000000</td>
      <td>Single u64 at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_BLOB_8</td>
      <td>0x80000000</td>
      <td>Series of u8, length (u8) + data (u8s) at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_BLOB_16</td>
      <td>0x90000000</td>
      <td>Series of u8, length (u16) + data (u8s) at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_BLOB_32</td>
      <td>0xa0000000</td>
      <td>Series of u8, length (u32) + data (u8s) at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_ARRAY</td>
      <td>0xd0000000</td>
      <td>Series of same type, length (u32) + data at low-as-off</td>
    </tr>
    <tr>
      <td>ADB_TYPE_OBJECT</td>
      <td>0xe0000000</td>
      <td>Series of different type, length (u32) + data at low-as-off</td>
    </tr>
  </tbody>
</table>

<p>As we briefly mentioned earlier, as each element does not record its own ID yet the ID has special meaning, to mark an empty, skipped element, the elements shall be special value 0; e.g. if a package has only a ID 6 field to declare, then it would have first 5 slots all set to 0 so they’re skipped, and there would be no ID 7 field onwards.</p>

<p>These slots are numbered as follows:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Data Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>NAME</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>2</td>
      <td>VERSION</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>3</td>
      <td>HASHES</td>
      <td>BLOB, usually BLOB_8, as hex-string</td>
    </tr>
    <tr>
      <td>4</td>
      <td>DESCRIPTION</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>5</td>
      <td>ARCH</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>6</td>
      <td>LICENSE</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>7</td>
      <td>ORIGIN</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>8</td>
      <td>MAINTAINER</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>9</td>
      <td>URL</td>
      <td>BLOB, usually BLOB_8, as string</td>
    </tr>
    <tr>
      <td>10</td>
      <td>REPO_COMMIT</td>
      <td>BLOB, usually BLOB_8, as hex-string</td>
    </tr>
    <tr>
      <td>11</td>
      <td>BUILD_TIME</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>12</td>
      <td>INSTALLED_SIZE</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>13</td>
      <td>FILE_SIZE</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>14</td>
      <td>PROVIDER_PRIORITY</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>15</td>
      <td>DEPENDS</td>
      <td>OBJECT of dependency (see below)</td>
    </tr>
    <tr>
      <td>16</td>
      <td>PROVIDES</td>
      <td>OBJECT of dependency (see below)</td>
    </tr>
    <tr>
      <td>17</td>
      <td>REPLACES</td>
      <td>OBJECT of dependency (see below)</td>
    </tr>
    <tr>
      <td>18</td>
      <td>INSTALL_IF</td>
      <td>OBJECT of dependency (see below)</td>
    </tr>
    <tr>
      <td>19</td>
      <td>RECOMMENDS</td>
      <td>OBJECT of dependency (see below)</td>
    </tr>
    <tr>
      <td>20</td>
      <td>LAYER</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>21</td>
      <td>TAGS</td>
      <td>OBJECT of BLOB, usually BLOB_8, as string array</td>
    </tr>
  </tbody>
</table>

<p>So e.g. the first element, <code class="language-plaintext highlighter-rouge">u32(body[4808:4812]) == 0x80000008</code>, is not zero, means the package has an actual name, the high <code class="language-plaintext highlighter-rouge">0x8</code> means this is a <code class="language-plaintext highlighter-rouge">BLOB_8</code> item, and the low <code class="language-plaintext highlighter-rouge">0x8</code> means the item’s length is at offset 8 and content starts at offset 9, so, <code class="language-plaintext highlighter-rouge">length == u8(body[12+8]) == 8</code>, and therefore content is at <code class="language-plaintext highlighter-rouge">body[12+9:12+9+8] == body[21:29]</code>, the example package is <code class="language-plaintext highlighter-rouge">crowdsec</code></p>

<p>And e.g. the last element ID 16 <code class="language-plaintext highlighter-rouge">u32(body[4868:4872]) == 0xe0000184</code>, is not zero, means the package has an actual <code class="language-plaintext highlighter-rouge">provides</code> <code class="language-plaintext highlighter-rouge">OBJECT</code> (<code class="language-plaintext highlighter-rouge">0xe0...</code>), offset <code class="language-plaintext highlighter-rouge">0x184 == 388</code> in payload, so <code class="language-plaintext highlighter-rouge">u32(body[12+388:12+388+4]) == u32(body[400:404]) == 2</code> records the number of sub-elements including the count itself, therefore 1 actual element, <code class="language-plaintext highlighter-rouge">adb_val_t(body[404:408])</code> therefore records the info of the only sub-element, here being <code class="language-plaintext highlighter-rouge">0xe000017c</code> means it’s yet another <code class="language-plaintext highlighter-rouge">OBJECT</code> starting at offset <code class="language-plaintext highlighter-rouge">0x17c</code>, …, and then <code class="language-plaintext highlighter-rouge">u32(body[392:396]) == 2</code> so there’s again 1 real sub-element, <code class="language-plaintext highlighter-rouge">adb_val_t(body[396:400]) == 0x8000016c</code> means this is a BLOB_8 starting at <code class="language-plaintext highlighter-rouge">0x16c</code>, then we get length from <code class="language-plaintext highlighter-rouge">u8(body[12+0x16c]) == 12</code>, and finally the provide item at <code class="language-plaintext highlighter-rouge">body[12+0x16c+1:+12] == body[377:389]</code>, being <code class="language-plaintext highlighter-rouge">crowdsec-any</code></p>

<p>While the <code class="language-plaintext highlighter-rouge">provides</code> seems a list of list of BLOB_8, but recall that <code class="language-plaintext highlighter-rouge">OBJECT</code> elements can be different types, each element in <code class="language-plaintext highlighter-rouge">provides</code> is actually a strongly-typed dep info, containing the <code class="language-plaintext highlighter-rouge">NAME</code> slot (BLOB_8, ID1), <code class="language-plaintext highlighter-rouge">VERSION</code> slot (BLOB_8, ID2), and <code class="language-plaintext highlighter-rouge">MATCH</code> slot (INT, ID3, for vercmp operations). In the example there’s just no <code class="language-plaintext highlighter-rouge">VERSION</code> nor <code class="language-plaintext highlighter-rouge">MATCH</code>.</p>

<p>All dependency-like element can have these 3 slots:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Data Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>NAME</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>2</td>
      <td>VERSION</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>3</td>
      <td>MATCH</td>
      <td>INT, usually embedded</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">MATCH</code> field is a bitwise OR of the following base bits:</p>

<table>
  <thead>
    <tr>
      <th>NAME</th>
      <th>VALUE</th>
      <th>bit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>EQUAL</td>
      <td>1</td>
      <td>0b00001</td>
    </tr>
    <tr>
      <td>LESS</td>
      <td>2</td>
      <td>0b00010</td>
    </tr>
    <tr>
      <td>GREATER</td>
      <td>4</td>
      <td>0b00100</td>
    </tr>
    <tr>
      <td>FUZZY</td>
      <td>8</td>
      <td>0b01000</td>
    </tr>
    <tr>
      <td>CONFLICT</td>
      <td>16</td>
      <td>0b10000</td>
    </tr>
  </tbody>
</table>

<p>The implementation would pre-calculate all <strong>valid</strong> combinations of these; these are (excluding <code class="language-plaintext highlighter-rouge">CONFLICT</code> which can be freely appended):</p>

<table>
  <thead>
    <tr>
      <th>Sign</th>
      <th>Meaning</th>
      <th>Bits</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>&lt;</td>
      <td>Less than</td>
      <td>0b0010</td>
    </tr>
    <tr>
      <td>&lt;=</td>
      <td>Less than or equal to</td>
      <td>0b0011</td>
    </tr>
    <tr>
      <td>&lt;~</td>
      <td>less than or equal to, fuzzy</td>
      <td>0b1011</td>
    </tr>
    <tr>
      <td>~</td>
      <td>Equal to, fuzzy</td>
      <td>0b1001</td>
    </tr>
    <tr>
      <td>=</td>
      <td>Equal to</td>
      <td>0b0001</td>
    </tr>
    <tr>
      <td>&gt;=</td>
      <td>Greater than or equal to</td>
      <td>0b0101</td>
    </tr>
    <tr>
      <td>&gt;~</td>
      <td>Greater than or equal to, fuzzy</td>
      <td>0b1101</td>
    </tr>
    <tr>
      <td>&gt;</td>
      <td>Greater than</td>
      <td>0b0100</td>
    </tr>
    <tr>
      <td>&gt;&lt;</td>
      <td>Special, checksum</td>
      <td>0b0110</td>
    </tr>
    <tr>
      <td>_</td>
      <td>Special, any</td>
      <td>0b0111</td>
    </tr>
  </tbody>
</table>

<p>So e.g. a match field with value <code class="language-plaintext highlighter-rouge">0x10000003</code> would mean <code class="language-plaintext highlighter-rouge">INT</code> with value <code class="language-plaintext highlighter-rouge">0x3 == 0b11</code>, therefore <code class="language-plaintext highlighter-rouge">&lt;=</code></p>

<h3 id="adbi_pkg_paths-paths">ADBI_PKG_PATHS: Paths</h3>

<p>Files and folders are stored in <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> in a compact way, before the latter possible file data appearance in <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code> blocks. Each of these path elements stores a folder path without the leading / (<em>empty path for root folder</em>), then any number of direct file entries. While most of the file entries do need their <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code> block for actual data, others could exist purely in header.</p>

<p>ID 2 in <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code>’s root object, which marks the head of paths, is yet another object, e.g. <code class="language-plaintext highlighter-rouge">root_obj[2] == body[5652:5656] ==  0xe00012fc</code>, means an object with offset 0x12fc, and <code class="language-plaintext highlighter-rouge">u32(body[12+0x12fc:+4]) == u32(body[4872:4876]) == 22</code> means there’re 21 <code class="language-plaintext highlighter-rouge">adb_val_t</code> after the count u32, 1st at <code class="language-plaintext highlighter-rouge">body[4872+4*1:+4] == body[4876:4880]</code> and 21st at <code class="language-plaintext highlighter-rouge">body[4872+4*21:+4] == body[4956:4960]</code></p>

<p>Each one of these 21 “path”s is actually called <code class="language-plaintext highlighter-rouge">ADBI_DI</code> by <code class="language-plaintext highlighter-rouge">apk-tools</code>, and is also an <code class="language-plaintext highlighter-rouge">OBJECT</code> with the following slots (still, some are optional):</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>Data Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>NAME</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ACL</td>
      <td>OBJECT being ACL info (see below)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>FILES</td>
      <td>OBJECT of File info (see below)</td>
    </tr>
  </tbody>
</table>

<p>In the example the last “path” <code class="language-plaintext highlighter-rouge">adb_val_t(body[4956:4960]) == 0xe0001290</code>, so it’s an <code class="language-plaintext highlighter-rouge">OBJECT</code> starting from <code class="language-plaintext highlighter-rouge">12 + 0x1290 == 4764</code>, as <code class="language-plaintext highlighter-rouge">u32(body[4764:4768]) == 4</code> there’re 3 slots after it.</p>

<p>For ID 1, NAME, <code class="language-plaintext highlighter-rouge">adb_val_t(body[4768:4772]) == 0x80001258</code>, it’s a <code class="language-plaintext highlighter-rouge">BLOB_8</code> starting at offset <code class="language-plaintext highlighter-rouge">0x1258</code>, and <code class="language-plaintext highlighter-rouge">u8(body[12+0x1258]) == u8(body[4708]) == 7</code> says this is a 7-length string, content at <code class="language-plaintext highlighter-rouge">body[4708+1:7] == body[4709:4716] == b"usr/bin"</code> says the folder name/path is <code class="language-plaintext highlighter-rouge">usr/bin</code></p>

<p>For ID2, ACL, <code class="language-plaintext highlighter-rouge">adb_val_t(body[4772:4776]) == 0xe0000194</code>, it’s an <code class="language-plaintext highlighter-rouge">OBJECT</code> starting at offset <code class="language-plaintext highlighter-rouge">0x194</code>, the count <code class="language-plaintext highlighter-rouge">u32(body[12+0x194:+4]) == u32(body[416:420]) == 4</code> so there’re 3 slots after it.</p>

<p>The ACL info OBJECT could have the following slots:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>Data Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>MODE</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>2</td>
      <td>USER</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>3</td>
      <td>GROUP</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>4</td>
      <td>XATTRS</td>
      <td>OBJECT of BLOB, usually BLOB_8, each BLOB_8 with <code class="language-plaintext highlighter-rouge">\0</code> as sep for name and value</td>
    </tr>
  </tbody>
</table>

<p>In the example:</p>
<ul>
  <li>SLOT1 reads <code class="language-plaintext highlighter-rouge">0x100001ed</code> so it’s an <code class="language-plaintext highlighter-rouge">INT</code> with value <code class="language-plaintext highlighter-rouge">0x1ed == 0o755</code></li>
  <li>SLOT2 reads <code class="language-plaintext highlighter-rouge">0x8000018c</code> so it’s an <code class="language-plaintext highlighter-rouge">BLOB_8</code> starting at offset <code class="language-plaintext highlighter-rouge">0x18c</code>, length at <code class="language-plaintext highlighter-rouge">body[408]</code> reads <code class="language-plaintext highlighter-rouge">4</code> and content at <code class="language-plaintext highlighter-rouge">body[409:413]</code> reads <code class="language-plaintext highlighter-rouge">root</code></li>
  <li>SLOT3 reads the same value so it reuses <code class="language-plaintext highlighter-rouge">root</code> from <code class="language-plaintext highlighter-rouge">USER</code></li>
  <li>There’s not SLOT4</li>
</ul>

<p>For ID3, FILES, <code class="language-plaintext highlighter-rouge">adb_val_t(body[4776:4780]) == 0xe0001260</code>, it’s an <code class="language-plaintext highlighter-rouge">OBJECT</code> starting at offset <code class="language-plaintext highlighter-rouge">0x1260</code>, the count <code class="language-plaintext highlighter-rouge">u32(body[12+0x1260:+4]) == u32(body[4716:4720]) == 12</code>, so there’re 11 file entries after it, the first at <code class="language-plaintext highlighter-rouge">body[4720:4724]</code> and the last at <code class="language-plaintext highlighter-rouge">body[4760:4764]</code>.</p>

<p>The first file entry, <code class="language-plaintext highlighter-rouge">adb_val_t(body[4720:4724]) == 0xe0000f4c</code>, it’s an <code class="language-plaintext highlighter-rouge">OBJECT</code> starting at offset <code class="language-plaintext highlighter-rouge">0xf4c</code>, the count <code class="language-plaintext highlighter-rouge">u32(body[12+0xf4c:+4]) == u32(body[3928:3932]) == 6</code>, so there’re 5 slots after it.</p>

<p>The File info OBJECT could have the following slots:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>Data Type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>NAME</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
    <tr>
      <td>2</td>
      <td>ACL</td>
      <td>OBJECT being ACL info (see above)</td>
    </tr>
    <tr>
      <td>3</td>
      <td>SIZE</td>
      <td>INT, usually embedded</td>
    </tr>
    <tr>
      <td>4</td>
      <td>MTIME</td>
      <td>INT, usually INT32</td>
    </tr>
    <tr>
      <td>5</td>
      <td>HASHES</td>
      <td>BLOB, usually BLOB_8, hex-string</td>
    </tr>
    <tr>
      <td>6</td>
      <td>TARGET</td>
      <td>BLOB, usually BLOB_8, string</td>
    </tr>
  </tbody>
</table>

<p>In the example:</p>
<ul>
  <li>SLOT1 reads <code class="language-plaintext highlighter-rouge">0x80000008</code> so it’s an <code class="language-plaintext highlighter-rouge">BLOB_8</code> with length at offset 8, <code class="language-plaintext highlighter-rouge">u8(body[12+8]) == 8</code>, and content <code class="language-plaintext highlighter-rouge">body[12+8+1:+8] == b"crowdsec"</code></li>
  <li>SLOT2 reads <code class="language-plaintext highlighter-rouge">0xe0000194</code> so it’s again <code class="language-plaintext highlighter-rouge">0o755</code> owned by <code class="language-plaintext highlighter-rouge">root:root</code></li>
  <li>SLOT3 reads <code class="language-plaintext highlighter-rouge">0x133cd7e8</code> so it’s an <code class="language-plaintext highlighter-rouge">INT</code> with value <code class="language-plaintext highlighter-rouge">0x33cd7e8 == 54319080</code></li>
  <li>SLOT4 reads <code class="language-plaintext highlighter-rouge">0x200001e4</code> so it’s an <code class="language-plaintext highlighter-rouge">INT_32</code> at offset <code class="language-plaintext highlighter-rouge">0x1e4</code>, and <code class="language-plaintext highlighter-rouge">u32(body[12+0x1e4:+4]) == 1772344484</code> so mtime is <code class="language-plaintext highlighter-rouge">Sun Mar  1 05:54:44 UTC 2026</code></li>
  <li>SLOT5 reads <code class="language-plaintext highlighter-rouge">0x80000f28</code> so it’s an <code class="language-plaintext highlighter-rouge">BLOB_8</code> with length at offset 0xf28, <code class="language-plaintext highlighter-rouge">u8(body[12+0xf28]) == 32</code>, and content <code class="language-plaintext highlighter-rouge">body[12+0xf28+1:+32]</code> reads a hex-string, which is the SHA256 checksum of the file</li>
  <li>There’s no SLOT6, as this is a regular file</li>
</ul>

<p>A file can have its <code class="language-plaintext highlighter-rouge">SIZE</code> set to 0, being an empty file, and on top of it having <code class="language-plaintext highlighter-rouge">TARGET</code> set, so it either serves as a symlink or hardlink to the set target, or is a special <code class="language-plaintext highlighter-rouge">CHAR</code> / <code class="language-plaintext highlighter-rouge">DEV</code>.</p>

<p>Let’s use the third file entry under the same last path entry to examine what <code class="language-plaintext highlighter-rouge">TARGET</code> does, which is <code class="language-plaintext highlighter-rouge">adb_val_t(body[4728:4732]) == 0xe0000fdc</code>, it’s an OBJECT with offset 0xfdc, we read <code class="language-plaintext highlighter-rouge">u32(body[12+0xfdc:+4]) == u32(body[4072:4076]) == 7</code> so it does have 6th slot for <code class="language-plaintext highlighter-rouge">TARGET</code>; we read the name <code class="language-plaintext highlighter-rouge">adb_val_t(body[4076:4090]) == 0x80000fac</code> so name starts at offset 0xfac and <code class="language-plaintext highlighter-rouge">len == u8(body[12+0xfac]) == u8(body[4024]) == 5</code>, content is <code class="language-plaintext highlighter-rouge">body[4024+1:+5] == body[4025:4030] == b"cscli"</code>, so the link itself is <code class="language-plaintext highlighter-rouge">usr/bin/cscli</code>; we skip to SLOT 6 for <code class="language-plaintext highlighter-rouge">TARGET</code> which should be <code class="language-plaintext highlighter-rouge">adb_val_t(body[12 + 0xfdc + 4 * 6:+4]) == adb_val_t(body[4096:4100]) == 0x80000fb2</code>, so it’s a BLOB with offset <code class="language-plaintext highlighter-rouge">0xfb2</code>, then we read length at <code class="language-plaintext highlighter-rouge">u8(body[12+0xfb2]) == u8(body[4030]) == 23</code> so content is <code class="language-plaintext highlighter-rouge">body[4030+1:+23] == body[4031:4054] == b"\x00\xa0/usr/bin/crowdsec-cli"</code></p>

<p>The first two bytes in the <code class="language-plaintext highlighter-rouge">TARGET</code> determines the data type, and they shall be handled as one u16, and <code class="language-plaintext highlighter-rouge">u16(body[4031:4033]) == 40960 == 0o120000</code>, this is basically the same thing as you would expect from the <code class="language-plaintext highlighter-rouge">st_mode</code> field in a <code class="language-plaintext highlighter-rouge">struct stat</code> with already <code class="language-plaintext highlighter-rouge">S_IFMT</code> been bitwise AND. The following file type are supported:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Mask</th>
      <th>Content at target[2:]</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>S_IFBLK</td>
      <td>0o060000</td>
      <td>8-byte, as u64 for dev ID (major:minor combined)</td>
    </tr>
    <tr>
      <td>S_IFCHR</td>
      <td>0o020000</td>
      <td>8-byte, as u64 for dev ID (major:minor combined)</td>
    </tr>
    <tr>
      <td>S_IFIFO</td>
      <td>0o010000</td>
      <td>8-byte, as u64 for dev ID (major:minor combined)</td>
    </tr>
    <tr>
      <td>S_IFLNK</td>
      <td>0o120000</td>
      <td>any-length, for symlink target</td>
    </tr>
    <tr>
      <td>S_IFREG</td>
      <td>0o100000</td>
      <td>any-length, for hardlink target</td>
    </tr>
  </tbody>
</table>

<p>The real target for symlink is therefore <code class="language-plaintext highlighter-rouge">target[2:]</code>, so we know this symlink is <code class="language-plaintext highlighter-rouge">usr/bin/csli -&gt; /usr/bin/crowdsec-cli</code></p>

<p><strong>When reading through the <code class="language-plaintext highlighter-rouge">PATH</code>s info it’s recommended to store them for later lookup, as the <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code> blocks would only carry the file content, but not the names, paths, ownership, etc.</strong></p>

<h3 id="adbi_pkg_scripts-scripts">ADBI_PKG_SCRIPTS: scripts</h3>

<p>ID 3 in the <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> is <code class="language-plaintext highlighter-rouge">ADBI_PKG_SCRIPTS</code> which is an OBJECT with multiple <code class="language-plaintext highlighter-rouge">BLOB</code>s for package pre/post scripts.</p>

<p>e.g. <code class="language-plaintext highlighter-rouge">root_obj[3] == body[5656:5660] == 0xe00015e0</code>, means an object with offset 0x15e0, and <code class="language-plaintext highlighter-rouge">u32(body[12+0x15e0:+4]) == u32(body[5612:5616]) == 8</code> means there’re 7 <code class="language-plaintext highlighter-rouge">adb_val_t</code> after the count u32, 1st at <code class="language-plaintext highlighter-rouge">body[5612+4*1:+4] == body[5616:5620]</code> and 21st at <code class="language-plaintext highlighter-rouge">body[5612+4*7:+4] == body[5640:5644]</code></p>

<p>The scripts OBJECT could have the following slots</p>

<table>
  <tbody>
    <tr>
      <td>ID</td>
      <td>NAME</td>
    </tr>
    <tr>
      <td>1</td>
      <td>TRIGGER</td>
    </tr>
    <tr>
      <td>2</td>
      <td>PREINST</td>
    </tr>
    <tr>
      <td>3</td>
      <td>POSTINST</td>
    </tr>
    <tr>
      <td>4</td>
      <td>PREDEINST</td>
    </tr>
    <tr>
      <td>5</td>
      <td>POSTDEINST</td>
    </tr>
    <tr>
      <td>6</td>
      <td>PREUPGRADE</td>
    </tr>
    <tr>
      <td>7</td>
      <td>POSTUPGRADE</td>
    </tr>
  </tbody>
</table>

<p>All names except <code class="language-plaintext highlighter-rouge">TRIGGER</code> should tell the purpose just by its name. The <code class="language-plaintext highlighter-rouge">TRIGGER</code> one is special as it would be triggered on changes to paths listed in latter <code class="language-plaintext highlighter-rouge">ADBI_PKG_TRIGGERS</code>.</p>

<p>The example package has only <code class="language-plaintext highlighter-rouge">3/POSTINST</code>, <code class="language-plaintext highlighter-rouge">4/PREDEINST</code> and <code class="language-plaintext highlighter-rouge">7/POSTUPGRADE</code>. Take the last slot for example, <code class="language-plaintext highlighter-rouge">u32(body[5640:5644])</code> reads <code class="language-plaintext highlighter-rouge">0x800014e4</code> so it’s <code class="language-plaintext highlighter-rouge">BLOB_8</code> with offset <code class="language-plaintext highlighter-rouge">0x14e4</code>, read <code class="language-plaintext highlighter-rouge">u8(body[12+0x14e4]) == 251</code> so length is 251, therefore content is <code class="language-plaintext highlighter-rouge">body[12+0x14e4+1:+251] == body[5361:5612]</code>, <code class="language-plaintext highlighter-rouge">b'#!/bin/sh\nexport PKG_UPGRADE=1\n[ "${IPKG_NO_SCRIPT}" = "1" ] &amp;&amp; exit 0\n[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0\n. ${IPKG_INSTROOT}/lib/functions.sh\nexport root="${IPKG_INSTROOT}"\nexport pkgname="crowdsec"\nadd_group_and_user\ndefault_postinst\n'</code>, which prints as:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/sh</span>
<span class="nb">export </span><span class="nv">PKG_UPGRADE</span><span class="o">=</span>1
<span class="o">[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">IPKG_NO_SCRIPT</span><span class="k">}</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"1"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit </span>0
<span class="o">[</span> <span class="nt">-s</span> <span class="k">${</span><span class="nv">IPKG_INSTROOT</span><span class="k">}</span>/lib/functions.sh <span class="o">]</span> <span class="o">||</span> <span class="nb">exit </span>0
<span class="nb">.</span> <span class="k">${</span><span class="nv">IPKG_INSTROOT</span><span class="k">}</span>/lib/functions.sh
<span class="nb">export </span><span class="nv">root</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">IPKG_INSTROOT</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">export </span><span class="nv">pkgname</span><span class="o">=</span><span class="s2">"crowdsec"</span>
add_group_and_user
default_postinst

</code></pre></div></div>

<h3 id="adbi_pkg_triggers-triggers">ADBI_PKG_TRIGGERS: triggers</h3>

<p>ID 4 in the <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> is <code class="language-plaintext highlighter-rouge">ADBI_PKG_TRIGGERS</code> which is an OBJECT with multiple <code class="language-plaintext highlighter-rouge">BLOB</code>s that shall trigger the <code class="language-plaintext highlighter-rouge">TRIGGER</code> script to run.</p>

<p>Note it is totally valid that a package does not have <code class="language-plaintext highlighter-rouge">TRIGGER</code> script yet has multiuple <code class="language-plaintext highlighter-rouge">TRIGGERS</code> paths.</p>

<h3 id="the-signature-block-adb_block_sig">The signature block <code class="language-plaintext highlighter-rouge">ADB_BLOCK_SIG</code></h3>

<p>Such block must be after <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> and before <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code>.</p>

<p>The block begins with a 2-byte header, including a u8 <code class="language-plaintext highlighter-rouge">sign_ver</code> field for the version of signature (currently must be 0), and a u8 <code class="language-plaintext highlighter-rouge">hash_alg</code> field for the ID of the algorithm, defined in C as:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">adb_sign_hdr</span> <span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">sign_ver</span><span class="p">,</span> <span class="n">hash_alg</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The hash algorithm could be one of the following:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>NAME</th>
      <th>LENGTH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>NONE</td>
      <td>-</td>
    </tr>
    <tr>
      <td>2</td>
      <td>SHA1</td>
      <td>20</td>
    </tr>
    <tr>
      <td>3</td>
      <td>SHA256</td>
      <td>32</td>
    </tr>
    <tr>
      <td>4</td>
      <td>SHA512</td>
      <td>64</td>
    </tr>
    <tr>
      <td>5</td>
      <td>SHA256_160 (actually SHA2 160 variant)</td>
      <td>20</td>
    </tr>
  </tbody>
</table>

<p>The missing ID1 was MD5 whose support was dropped in apk-tools.</p>

<p>And currently <code class="language-plaintext highlighter-rouge">apk-tools</code> would only use <code class="language-plaintext highlighter-rouge">SHA512</code> for both signing and verifying.</p>

<p>If this have a valid, non-<code class="language-plaintext highlighter-rouge">NONE</code> hash_alg, then the actual payload should (after the 2-byte header) be followed by a 16-byte ID, and the corresponding length of signature, defined in C as:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">adb_sign_v0</span> <span class="p">{</span>
    <span class="k">struct</span> <span class="n">adb_sign_hdr</span> <span class="n">hdr</span><span class="p">;</span>
    <span class="kt">uint8_t</span> <span class="n">id</span><span class="p">[</span><span class="mi">16</span><span class="p">];</span>
    <span class="kt">uint8_t</span> <span class="n">sig</span><span class="p">[];</span>
<span class="p">};</span>
</code></pre></div></div>

<p>In the example file there’s no <code class="language-plaintext highlighter-rouge">SIG</code> block.</p>

<p>When testing signing with <code class="language-plaintext highlighter-rouge">apk-tools</code> (which can re-sign an unsigned v3 apk), the private key file <code class="language-plaintext highlighter-rouge">--sign-key</code> shall be an OpenSSL private key, which could be generated via e.g. <code class="language-plaintext highlighter-rouge">openssl genrsa -aes256 -out /tmp/private.pem 4096</code>, in which <code class="language-plaintext highlighter-rouge">-aes256</code> could be omitted if you don’t want password. However as this is a temporary key not in pool, the command should look like <code class="language-plaintext highlighter-rouge">apk adbsign --allow-untrusted --sign-key /tmp/private.pem crowdsec-1.6.2-r1.apk.resigned</code></p>

<p>The size of <code class="language-plaintext highlighter-rouge">sig[]</code> part shall follow what the key specifies, e.g. for the above <code class="language-plaintext highlighter-rouge">rsa4096</code> key, the signature shall be 512 bytes, and it might be <code class="language-plaintext highlighter-rouge">PKCS#1</code> message but as this is only testing with temporary key I can’t confirm the official repo signing method.</p>

<p>The following is output from <code class="language-plaintext highlighter-rouge">adumpk</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DEBUG... AdbBlock(type_block=&lt;AdbBlockType.SIG: 1&gt;, size_raw=534, size_payload=530)
INFO.... Hash sha512, 64 bytes, ID 290fb2a94d29dda681301285226e604d: CQT7OfNPmxt6XtW3s1iV5N6DtGlfkVYKYsjKn4LKsRmYW0RjTXhZ12bexzmcx7zIQqs9VMZYyN9ovCobYhnUDikR5an2FoUYIJ9oJAEm3FdS1Q5L0m7mSqssO6SP/Y8dK7G1wgnlvTLgKOQ4gWjVogOLCDFk2j/B15NmGMS3rS7hcYNPhn7SuDTBMzNM6jMNoe0ElYznFCZYEUw89Ow1rD602/sIhO6eZwuTrgsFBq6dBLLiOZ863ufiKnUVNW1PijmdPh730L8aqnlm1Jdro+eN4A5Af5zDsqobPaRlE1Rs/7UzTBozDAIcoPWTjtVkBUqEw8SWMdeAQnlBKkiOGmq5uGsM/KvgZb+NthME5YcsbWJLineVCuZ/iVZCAtSbKvFlPKRpwk385YnA/LMfdIuR7dsZQLjpzEdgYC5/57O/CWOs7WvBI4jXi0wiTqbEHKKSHhlmnJI7DdTwAesE86G5lgxqamnxIuG9xjD6Cm6l9fPYR3dcVAFl76FuLSLzDuT4J51o48F4MvlyfJIt5a+Thoknvhcg4OXEAJMg5tOc5uWU+TV1cllLqkeyAh1qxUCbol4mU5ZLctgMYGsSnCxISuDXNDy6k6D/m3ilz+9BOIrfKM2C6z7SBvCzmoezCMkr2oBdGHbgguSj9vkwwLXHzMbY7AZXRb0UQ3fIml4=
</code></pre></div></div>

<h3 id="the-data-block-adb_block_data">The data block <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code></h3>

<p>Such block must be after <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> and cannot be before <code class="language-plaintext highlighter-rouge">ADB_BLOCK_SIG</code></p>

<p>The block begins with a 8-byte header, including a u32 <code class="language-plaintext highlighter-rouge">path_idx</code> field for the 1-started ID of corresponding <code class="language-plaintext highlighter-rouge">PATH</code> element, and a u32 <code class="language-plaintext highlighter-rouge">file_idx</code> field for the 1-started ID of corresponding <code class="language-plaintext highlighter-rouge">FILE</code> element in that <code class="language-plaintext highlighter-rouge">PATH</code>, defined in C as:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">adb_data_package</span> <span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">path_idx</span><span class="p">;</span>
    <span class="kt">uint32_t</span> <span class="n">file_idx</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The file data follows directly after the header. Remember that the payload contains the header and each block starts at 8-byte boundary and aligns up to 8-byte boundary.</p>

<p>E.g. in the example file, right after <code class="language-plaintext highlighter-rouge">ADB_BLOCK_ADB</code> at <code class="language-plaintext highlighter-rouge">body[8:5660]</code>, pad that to 8-byte boundary <code class="language-plaintext highlighter-rouge">5664</code>, and reads the 4-byte type_size <code class="language-plaintext highlighter-rouge">u32(body[5664:5668]) == 0x8000007f</code>, so type for it is <code class="language-plaintext highlighter-rouge">0x8000007f &gt;&gt; 30 == 2</code>, for a <code class="language-plaintext highlighter-rouge">ADB_BLOCK_DATA</code>, and size for the payload including header is <code class="language-plaintext highlighter-rouge">0x7f</code>, 127, therefore the whole payload including header is <code class="language-plaintext highlighter-rouge">body[5664:+127] = body[5664:5791]</code>. In it, the path_idx is <code class="language-plaintext highlighter-rouge">u32(body[5668:5672]) == 3</code>, and file_idx is <code class="language-plaintext highlighter-rouge">u32(body[5672:5676]) == 1</code>, and actual data length is <code class="language-plaintext highlighter-rouge">127 (whole block) - 4 (block header) - 8 (data header) = 115</code>, and we can confirm it’s <code class="language-plaintext highlighter-rouge">body[5676:5791] = body[5676:+115]</code>.</p>

<p>The file content reads as below:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span> <span class="n">crowdsec</span> <span class="s1">'crowdsec'</span>
    <span class="n">option</span> <span class="n">data_dir</span> <span class="s1">'/srv/crowdsec/data'</span>
    <span class="n">option</span> <span class="n">db_path</span> <span class="s1">'/srv/crowdsec/data/crowdsec.db'</span>

</code></pre></div></div>

<p>And if we go back to look at the <code class="language-plaintext highlighter-rouge">PATH</code> block, we would know this is for folder <code class="language-plaintext highlighter-rouge">etc/config</code> and file <code class="language-plaintext highlighter-rouge">crowdsec</code>, perm <code class="language-plaintext highlighter-rouge">0o600</code> owned by <code class="language-plaintext highlighter-rouge">root:root</code>, with SHA256 checksum.</p>]]></content><author><name></name></author><category term="designdoc" /><summary type="html"><![CDATA[Background]]></summary></entry><entry><title type="html">Multi-architecture multi-distro in one root partition</title><link href="https://7ji.github.io/booting/2025/11/14/multi-arch-multi-distro-in-one-root-drive.html" rel="alternate" type="text/html" title="Multi-architecture multi-distro in one root partition" /><published>2025-11-14T10:30:00+00:00</published><updated>2025-11-14T10:30:00+00:00</updated><id>https://7ji.github.io/booting/2025/11/14/multi-arch-multi-distro-in-one-root-drive</id><content type="html" xml:base="https://7ji.github.io/booting/2025/11/14/multi-arch-multi-distro-in-one-root-drive.html"><![CDATA[<p>Recently I needed to do offline OS maintainance work on quite a few of my devices, for which I used Ventoy + archiso on x86_64 for Debian 13 / Arch, and ALARM os drive on aarch64 for Debain 13 / ALARM. For which I find archiso more and more annoying as I had to re-do a lot of initial setups.</p>

<p>While I know these could be improved if I have a dedicated persistent fs for configs, or cloud-init scripts, or archiso boot parameters, I don’t quite want immutable live system for the work any more. So, I decided, what if I have a single drive, on which I have all of the following systems booting from the same root partition?</p>

<ul>
  <li>Arch Linux x86_64 bootable via both UEFI and legacy</li>
  <li>Debian 13 x86_64 bootable via both UEFI and legacy</li>
  <li>Debian 13 aarch64 bootable via UEFI (and U-boot distroboot)</li>
  <li>Arch Linux ARM aarch64 bootable via both UEFI (and U-boot distroboot)</li>
  <li>And of course more!</li>
</ul>

<h2 id="background-knowledge">Background knowledge</h2>

<p>Before the actual installation, I’ll explain the background knowledge first. If you don’t bother with, skip to <a href="#drive-preparation">next chapter</a></p>

<h3 id="booting-on-x86_64-uefi-without-csm">Booting on x86_64 UEFI, without CSM</h3>

<p>On x86_64 UEFI, without CSM, the booting process is quite simple:</p>

<ol>
  <li>UEFI powers on and do preparation until booting logic is ready</li>
  <li>If quick boot is enabled, load only necessary drivers, and execute the first available (destination exists and binary exists) BootEntry in current BootOrder, if this succeeds then no remaining steps would be executed</li>
  <li>If external boot sources are available (e.g. network adapters with UEFI ROM) and not disabled, let them scan and register BootEntrys as needed; on most devices this step does not execute at all</li>
  <li>UEFI BIOS loads various drivers and scans for all drives for EFI partition, that is, MSDOS type “EFI (FAT-12/16/32)”, or GPT type “C12A7328-F81F-11D2-BA4B-00A0C93EC93B”, for each FAT fs with such type, open and search for EFI binary at removable path “EFI/BOOT/BOOTX64.EFI”, and register a BootEntry with generated name for it like “UEFI OS”; for different UEFI vendors the logic whether to register them with order earlier or later than existing entries are undetermined</li>
  <li>Go similarly as step 2</li>
</ol>

<p>Note about MBR on UEFI: as the specification only required support for GPT, the support or no-support for MBR is undeterminable before you get your hands on the actual machine. Windows and systemd-boot simply refuses to install on MBR on UEFI. While all of my devices support such and I use it for local system installation on small drives, I would only focus on GPT on UEFI due to the fact that we want the result drive bootable on variuos machines.</p>

<p>Most of UEFI-compatible boot managers support to be (or has to be at least) installed at removable path. E.g. for grub (expecting EFI partiton mounted at <code class="language-plaintext highlighter-rouge">/efi</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>grub-install --target x86_64-efi --removable
</code></pre></div></div>

<p>On Debian 13 this installs the following files to <code class="language-plaintext highlighter-rouge">/efi/EFI/BOOT</code>:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">BOOTX64.CSV</code>: this contains required entry to be registered once shim at <code class="language-plaintext highlighter-rouge">BOOTX64.EFI</code> successfully booted</li>
  <li><code class="language-plaintext highlighter-rouge">BOOTX64.EFI</code>: Debian’s signed shim, loads signed <code class="language-plaintext highlighter-rouge">grubx64.efi</code>, and register entries according to <code class="language-plaintext highlighter-rouge">BOOTX64.CSV</code>; the one UEFI firmware would pick as removable EFI</li>
  <li><code class="language-plaintext highlighter-rouge">grub.cfg</code>: Grub’s config, it just tells grub to scan for real root and look up configs there. An example content:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  search.fs_uuid 91c69930-b508-4a42-b510-d63544d7eae0 root hd1,gpt2
  set prefix=($root)'/boot/grub'
  configfile $prefix/grub.cfg
</code></pre></div>    </div>
    <p>Grub’s config files look like shell scripts and you can imagine <code class="language-plaintext highlighter-rouge">configfile</code> as <code class="language-plaintext highlighter-rouge">source</code> in shell, so in this case the <code class="language-plaintext highlighter-rouge">grub.cfg</code> in EFI partition just records where to find the root partition (search for fs with uuid <code class="language-plaintext highlighter-rouge">91c69930-b508-4a42-b510-d63544d7eae0</code> and records the result in variable <code class="language-plaintext highlighter-rouge">root</code>, if failed then use default <code class="language-plaintext highlighter-rouge">hd1,gpt2</code>), sets another variable <code class="language-plaintext highlighter-rouge">prefix</code>, being the path to folder <code class="language-plaintext highlighter-rouge">/boot/grub</code> under that root fs, then “source” another config <code class="language-plaintext highlighter-rouge">grub.cfg</code> under there.</p>
  </li>
  <li><code class="language-plaintext highlighter-rouge">grubx64.efi</code>: Grub’s core EFI binary, signed by Debian, loads <code class="language-plaintext highlighter-rouge">grub.cfg</code>
    <ul>
      <li>The file would carry a built-in <code class="language-plaintext highlighter-rouge">$prefix</code> variable equalling <code class="language-plaintext highlighter-rouge">/EFI/debian</code> to instruct where to look up for a grub folder; in removable case it would instead try <code class="language-plaintext highlighter-rouge">[ESP]/EFI/BOOT</code>, so it looks up <code class="language-plaintext highlighter-rouge">grub.cfg</code> here</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">mmx64.efi</code>: Machine owner key manager, only needed for secure boot, not needed for fully removable use cases, can be safely deleted</li>
</ul>

<p>On Arch Linux this installs only <code class="language-plaintext highlighter-rouge">BOOTX64.EFI</code>, even the config needs to be manually created.</p>

<p>For a removable drive, on which we would have (some) kernels unsigned, we certainly would not want strict Secure Boot, neither would we want permissive Secure Boot with Machine Owner Key managed by ourselves. And we would not want it to register non-removable UEFI BootEntry s if possible.</p>

<p>The dependency tree in Debain’s Grub split packages make it really hard to install secure-boot-less under UEFI, as grub-efi-amd64-bin, a soft depened making <code class="language-plaintext highlighter-rouge">grub-install --target x86_64-efi</code> possible, hard depends grub-efi-amd64-signed. And the whole dependency tree becomes locked-in and almost impossible to uninstall due to they considered “essential”. And dpkg hook would “friendly” help use to re-install (update) Grub on version change, not respecting the existing layout. So in later steps we would install and manage Grub the boot loader part from Arch Linux, and install only the booting configuration generation part on Debian.</p>

<p>Don’t worry about “Debian not having its own Grub”. It is <code class="language-plaintext highlighter-rouge">$prefix/grub.cfg</code> that normally <code class="language-plaintext highlighter-rouge">grub-mkconfig</code> / <code class="language-plaintext highlighter-rouge">update-grub</code> updates, these are managed by sytem themselves, and the configuration tool would still be installed.</p>

<p>Other than the per-system <code class="language-plaintext highlighter-rouge">grub.cfg</code>, we would want an “outer grub.cfg”, which is directly used by Grub, containing <code class="language-plaintext highlighter-rouge">menuentry</code> to instruct which sub <code class="language-plaintext highlighter-rouge">grub.cfg</code> to redirect to.</p>

<p>The reason we choose Grub rather than other booting methods:</p>
<ul>
  <li>While systemd-boot is also a candidate on x86_64 UEFI, the straight-forward installtion tool <code class="language-plaintext highlighter-rouge">bootctl</code> does not natively support a removable option (so it must write a BootEntry <strong>at installation</strong> which I find quite annoying); although you could manually copy the binary to the removable EFI path, no maintainance can be done easily with <code class="language-plaintext highlighter-rouge">bootctl update</code>; also systemd-boot does not support legacy BIOS so we have to maintain more booting configs, which is a hassle itself</li>
  <li>Placing a unified kernel image to the removable place is OK if you only want a single distro, but is impossible if we want multiple distro (well technically you can use kernel image cross-distro, but good luck updating them)</li>
</ul>

<p>In summary, for the multi-boot logic we would need: GPT + EFI partition + one single removable Grub EFI binary + one single <code class="language-plaintext highlighter-rouge">grub.cfg</code> as <code class="language-plaintext highlighter-rouge">menuentry</code> selector + one <code class="language-plaintext highlighter-rouge">grub.cfg</code> per system maintained by the system itself</p>

<h3 id="booting-on-x86-legacy-bios-or-on-x86_64-uefi-with-csm">Booting on x86 legacy BIOS or on x86_64 UEFI with CSM</h3>

<p>This is still “simple” but not straight-forward in the modern perspective. Still, let’s write the main ideas down:</p>

<ol>
  <li>BIOS powers on and do preparation until booting logic is ready</li>
  <li>BIOS registers newly found drives into its pool, not necessarily at last positions</li>
  <li>For each target in the booting order configuration, if not drive, delegate to external source (e.g. network manager with booting ROM), otherwide load the Master Boot Record (MBR) on sector 0 and try to execute it; in most cases this wouldn’t return even if the MBR is not technically bootable (some partition tools would place a binary here to print “unbootable device”, and for some this means hang i.e. soft locked)</li>
  <li>If all drives in booting order failed, print “no bootable drives found” and hang</li>
</ol>

<p>Note while <code class="language-plaintext highlighter-rouge">MBR</code> was mentioned above, the partition table on the drive does not have to be <code class="language-plaintext highlighter-rouge">MBR / msdos</code>. The BIOS actually knows nothing above the partition but rather just reads stuff from fixed offsets (think that as “as-if MBR0”, in fact the whole drive can be a “super floppy” i.e. fs on whole drive, and as long as sector 0 is available it does not matter).</p>

<p>So most of the legacy-BIOS-compatible boot loaders need to be installed to MBR, or also technically the whole drive, e.g. for Grub:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>grub-install --target i386-pc /dev/sda
</code></pre></div></div>

<p>The binary that Grub installs into MBR / sector 0 is called <code class="language-plaintext highlighter-rouge">boot.img</code> by Grub itself, the functionality is similar to <code class="language-plaintext highlighter-rouge">grubx64.efi</code> in the UEFI case: to load necessary drivers to look up real <code class="language-plaintext highlighter-rouge">grub.cfg</code>. But as MBR sector 0 is too small (512 Byte) it’s impossible to do it by the small <code class="language-plaintext highlighter-rouge">boot.img</code> itself. For this another part of binary called <code class="language-plaintext highlighter-rouge">core.img</code> needs to be looked up and executed. Grub does this differently depending on whether you’re booting on MBR or GPT:</p>

<ul>
  <li>on MBR, <code class="language-plaintext highlighter-rouge">core.img</code> is stored from sector 1 onward, before first partition, the space is 1 MiB - 512B, and in real world would never be fully utilized.</li>
  <li>on GPT, <code class="language-plaintext highlighter-rouge">core.img</code> is stored in the partition with type <code class="language-plaintext highlighter-rouge">BIOS boot</code> (21686148-6449-6E6F-744E-656564454649), for the same reason this can be only 1 MiB</li>
</ul>

<p>Of course <code class="language-plaintext highlighter-rouge">core.img</code> itself would carry metadata including how large the actual data is, fs drivers so actual root partition could be opened, and optionally a built-in config.</p>

<p>The logic is simply, <code class="language-plaintext highlighter-rouge">boot.img</code> -&gt; <code class="language-plaintext highlighter-rouge">core.img</code> -&gt; <code class="language-plaintext highlighter-rouge">$prefix/grub.cfg</code>, after that latter steps are similar to UEFI cases.</p>

<p>Similar as UEFI, we would want a single outer Grub with config managed by ourselves to function as selector, and each system managing their own inner <code class="language-plaintext highlighter-rouge">grub.cfg</code> (but not having their Grub boot manager) ready to be picked up by ourselves.</p>

<p>The reason we choose Grub rather than other booting methods:</p>
<ul>
  <li>While syslinux can also provide menu, and is pretty KISS, it would need seperate config, instead of reusing the same config as UEFI</li>
</ul>

<p>In summary, for the multi-boot logic we would need: GPT + BIOS Boot partition + one single Grub binary in MBR0 and BIOS Boot partition + one single <code class="language-plaintext highlighter-rouge">grub.cfg</code> as <code class="language-plaintext highlighter-rouge">menuentry</code> selector + one <code class="language-plaintext highlighter-rouge">grub.cfg</code> per system maintained by the system itself</p>

<h3 id="booting-on-aarch64-uefi-or-u-boot-distroboot">Booting on AArch64, UEFI or U-Boot distroboot</h3>

<p>Some AArch64 devices support UEFI, many others don’t and use U-Boot. In not-too-old U-Boot builds, the “distroboot” concept scans for various bootable targets, and removable EFI binary <code class="language-plaintext highlighter-rouge">EFI/BOOT/BOOTAA64.EFI</code> is one of them, besides <code class="language-plaintext highlighter-rouge">extlinux.conf</code> and <code class="language-plaintext highlighter-rouge">boot.scr(.uimg)</code> . I build u-boot myself on all of SBCs and TV boxes on my hand and they all support such without explicitly enabling. As we want multi-boot on a single drive on both AArch64 and x86_64 we would only focus the UEFI and distroboot UEFI.</p>

<p>On AArch64 UEFI, the steps are similar as <a href="#booting-on-x86_64-uefi-without-csm">x86_64</a>, except that the fallback EFI binary name is <code class="language-plaintext highlighter-rouge">BOOTAA64.EFI</code></p>

<p>On AArch64 U-Boot that chainloads removable EFI in distroboot logic:</p>
<ol>
  <li>U-Boot powers on and do preparation until booting logic is ready</li>
  <li>Loads environment variables either from persistent storage or from built-in, in both cases to memory</li>
  <li>Run environment variable <code class="language-plaintext highlighter-rouge">bootcmd</code>, in modern cases, <code class="language-plaintext highlighter-rouge">bootcmd='bootflow scan -lb'</code></li>
  <li>So, run <code class="language-plaintext highlighter-rouge">bootflow</code>, the argument <code class="language-plaintext highlighter-rouge">scan</code> means to scan all possible sources, in most cases these include all block devices first, then network; the argument <code class="language-plaintext highlighter-rouge">-l</code> means to print each scanned bootablt target; the argument <code class="language-plaintext highlighter-rouge">-b</code> means for each target scanned, try to boot immediately.</li>
  <li>Let’s only focus on removable/fallback EFI binary, and assumes it being the only possible target and scanned</li>
  <li>Prepare some “UEFI” environments and “UEFI services”, then loads the EFI binary in, then execute it.</li>
</ol>

<p>Note specifically for per-device DTB to be applied correctly in the U-Boot case, if <code class="language-plaintext highlighter-rouge">/boot</code> is in root fs, the job cannot be done by Grub (cannot expect a partition to be readable before you could even tell there’s a block device), rather the DTB has to be loaded by U-Boot</p>

<h2 id="drive-preparation">Drive preparation</h2>

<p>Boot archiso or do this in a device already running Linux.</p>

<p>Run your perferred partition tool to partition the drive with the following partitions:</p>
<ul>
  <li>100 MiB EFI system partition</li>
  <li>1 MiB BIOT boot partition</li>
  <li>Remaining as a single root partition</li>
  <li>Others as you like</li>
</ul>

<p>Or simply save the following infos in a temporary file e.g. <code class="language-plaintext highlighter-rouge">parts.info</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>label: gpt
unit: sectors
sector-size: 512

size=204800, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B
size=2048, type=21686148-6449-6E6F-744E-656564454649
type=0FC63DAF-8483-4772-8E79-3D69D8477DE4
</code></pre></div></div>

<p>Then run <code class="language-plaintext highlighter-rouge">sfdisk</code> to format use the info:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sfdisk /dev/[drive] &lt; parts.info
</code></pre></div></div>

<p>The result partitions shall look like the following (<code class="language-plaintext highlighter-rouge">/dev/vda</code> is used in the following example):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Checking that no-one is using this disk right now ... OK

Disk /dev/[drive]: 64 GiB, 68719476736 bytes, 134217728 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

&gt;&gt;&gt; Script header accepted.
&gt;&gt;&gt; Script header accepted.
&gt;&gt;&gt; Script header accepted.
&gt;&gt;&gt; Created a new GPT disklabel (GUID: D8997F52-AFD6-4A0F-845F-FF12CDD718F6).
/dev/[drive]1: Created a new partition 1 of type 'EFI System' and of size 100 MiB.
/dev/[drive]2: Created a new partition 2 of type 'BIOS boot' and of size 1 MiB.
/dev/[drive]3: Created a new partition 3 of type 'Linux filesystem' and of size 63.9 GiB.
/dev/[drive]4: Done.

New situation:
Disklabel type: gpt
Disk identifier: D8997F52-AFD6-4A0F-845F-FF12CDD718F6

Device      Start       End   Sectors  Size Type
/dev/[drive]1    2048    206847    204800  100M EFI System
/dev/[drive]2  206848    208895      2048    1M BIOS boot
/dev/[drive]3  208896 134215679 134006784 63.9G Linux filesystem

The partition table has been altered.
</code></pre></div></div>

<p>Create a FAT fs on the ESP:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkfs.vfat /dev/[drive]1
</code></pre></div></div>

<p>Create a Btrfs on the root partition:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkfs.btrfs /dev/[drive]3
</code></pre></div></div>

<p>Note about Btrfs compression: if you want it, you could set it at fs-creation time, e.g.:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkfs.btrfs --compress zstd:15 /dev/[drive]3
</code></pre></div></div>
<p>But the later steps assume this not set and we would write the compression level manually at mount time and in fstab. You can omit those if you did this at fs-creation time.</p>

<p>Now mount the Btrfs root partition to somewhere, we need to create some subvols</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mount --mkdir /dev/[drive]3 /mnt/manyos
cd /mnt/manyos
</code></pre></div></div>

<p>Let’s create subvols. The main focus is that we want seperate root volumes for each system, and shared home. (<code class="language-plaintext highlighter-rouge">@</code> or <code class="language-plaintext highlighter-rouge">+</code> is not strictly needed in subvol names, but it helps to tell them from plain folders)</p>
<ul>
  <li>The generic one subvol per system except shared home style (very simple for latter mounting and fstab):
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>btrfs subvolume create @arch @debian @home
</code></pre></div>    </div>
  </li>
  <li>My style:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkdir shared arch-x86_64 debian-x86_64 alarm-aarch64 debian-aarch64
btrfs subvolume create shared/@{home{,_.snapshots},etc_ssh} {arch-x86_64,debian-x86_64,alarm-aarch64,debian-aarch64}/{@{,.snapshots},+nocow}
chattr +C {arch-x86_64,debian-x86_64,alarm-aarch64,debian-aarch64}/+nocow
mkdir -p arch-x86_64/+nocow/var/{cache,log,spool,tmp}
chmod 1777 arch-x86_64/+nocow/var/tmp
mkdir template
tar -f template/nocow.tar -C arch-x86_64/+nocow -cv .
for i in arch-x86_64/@ {debian-x86_64,alarm-aarch64,debian-aarch64}/{@,+nocow}; do tar -f template/nocow.tar -C $i -xv; done
</code></pre></div>    </div>
    <p>The layout shall look the the following:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; tree
.
├── arch-x86_64
│   ├── @
│   │   └── var
│   │       ├── cache
│   │       ├── log
│   │       ├── spool
│   │       └── tmp
│   ├── +nocow
│   │   └── var
│   │       ├── cache
│   │       ├── log
│   │       ├── spool
│   │       └── tmp
│   └── @.snapshots
├── ...
├── shared
│   ├── @home
│   └── @home_.snapshots
└── template
    └── nocow.tar

34 directories, 1 file
</code></pre></div>    </div>
    <p>The benefit of the above layout is that stuffs not needing snapshot and compression are enclosed into a single <code class="language-plaintext highlighter-rouge">+nocow</code> subvol and bind-mounting is used instead of many subvols; and system-specific stuffs are enclosed into one single top-level folder, and shared stuffs not so, and .snapshots subvol for snapper is pre-created.</p>
  </li>
</ul>

<p>In later steps I would follow only my own style</p>

<h2 id="installing-arch-linux-x86_64">Installing Arch Linux x86_64</h2>

<p>As discussed earlier we would use Arch Linux as the one to install and maintain Grub x86_64 (as, Grub almost HAS TO BE installed as Secure Boot with shim, which we don’t need at all), so let’s install Arch Linux first.</p>

<p>Mainly you shall follow <a href="https://wiki.archlinux.org/title/Installation_guide">the official Installation Guide</a> for the most part, to boot archiso on x86_64 and install in UEFI style; of course you could also do this on a device already running Linux. I would only cover the archiso case.</p>

<p>Let’s focus on things that shall go differently from the official way:</p>

<ol>
  <li>Follow the official guide, until before “Partition the disks”</li>
  <li>Skip “Partition the disks”</li>
  <li>Skip “Format the partitions”</li>
  <li>To mount my layout, mount root and nocow subvol first:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount -o compress=zstd:15,subvol=arch-x86_64/@ --mkdir /dev/[drive]3 /mnt/root
 mount -o subvol=arch-x86_64/+nocow --mkdir /dev/[drive]3 /mnt/arch-x86_64+nocow
</code></pre></div>    </div>
    <p>Then pre-create fstab and edit it:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mkdir /mnt/root/etc
 cp /etc/fstab /mnt/root/etc/
 vim /mnt/root/etc/fstab
</code></pre></div>    </div>
    <p>Remember to use vim’s functionality to do multi-line edit (shift + v, crtl + v, etc) and the ability to pipe content into external command (select in multi-line visual, then <code class="language-plaintext highlighter-rouge">:</code>, then <code class="language-plaintext highlighter-rouge">!column -t</code> to force a table look)</p>

    <p>The content shall look like the following (remember to use <code class="language-plaintext highlighter-rouge">blkid</code> to acquire your real UUID for root partition and ESP)</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> # Static information about the filesystems.
 # See fstab(5) for details.

 # &lt;file system&gt; &lt;dir&gt; &lt;type&gt; &lt;options&gt; &lt;dump&gt; &lt;pass&gt;
 UUID=6894094c-a75e-4f1a-b228-283faf7bf003  /                       btrfs  rw,compress=zstd:15,subvol=arch-x86_64/@            0  0
 UUID=6894094c-a75e-4f1a-b228-283faf7bf003  /.snapshots             btrfs  rw,compress=zstd:15,subvol=arch-x86_64/@.snapshots  0  0
 UUID=6894094c-a75e-4f1a-b228-283faf7bf003  /home                   btrfs  rw,compress=zstd:15,subvol=shared/@home             0  0
 UUID=6894094c-a75e-4f1a-b228-283faf7bf003  /home/.snapshots        btrfs  rw,compress=zstd:15,subvol=shared/@home_.snapshots  0  0
 UUID=6894094c-a75e-4f1a-b228-283faf7bf003  /mnt/arch-x86_64+nocow  btrfs  rw,compress=zstd:15,subvol=arch-x86_64/+nocow       0  0
 /mnt/arch-x86_64+nocow/var/cache           /var/cache              none   bind,private                                        0  0
 /mnt/arch-x86_64+nocow/var/log             /var/log                none   bind,private                                        0  0
 /mnt/arch-x86_64+nocow/var/spool           /var/spool              none   bind,private                                        0  0
 /mnt/arch-x86_64+nocow/var/tmp             /var/tmp                none   bind,private                                        0  0
 UUID=5D03-ED7A                             /efi                    vfat   rw,noatime                                          0  2
</code></pre></div>    </div>

    <p>Then mount everything remaining up:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount --all --fstab /mnt/root/etc/fstab --target-prefix /mnt/root --mkdir
</code></pre></div>    </div>

    <p>A <code class="language-plaintext highlighter-rouge">lsblk</code> shall look like following now:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> # lsblk
 NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
 vda    254:0    0   128G  0 disk
 ├─vda1 254:1    0   100M  0 part /mnt/root/efi
 ├─vda2 254:2    0     1M  0 part
 └─vda3 254:3    0 127.9G  0 part /mnt/root/var/tmp
                                  /mnt/root/var/spool
                                  /mnt/root/var/log
                                  /mnt/root/var/cache
                                  /mnt/root/mnt/arch-x86_64+nocow
                                  /mnt/root/home/.snapshots
                                  /mnt/root/home
                                  /mnt/root/.snapshots
                                  /mnt/arch-x86_64+nocow
                                  /mnt/root
</code></pre></div>    </div>
  </li>
  <li>Continue from “Installation”, I’d recommend to choose the following bootstrap packages (pre-configure booster so initramfs is only generated once for universal, skipping the non-universal one):
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> echo 'universal: true' &gt; /mnt/root/etc/booster.yaml
 pacstrap -K /mnt/root base booster linux intel-ucode amd-ucode linux-firmware btrfs-progs dosfstools grub vim sudo
</code></pre></div>    </div>
  </li>
  <li>Skip “Configure the system / Fstab”, the fstab generated on our Btrfs layout is pretty messy, just use our own</li>
  <li>Continue from “Chroot”, <code class="language-plaintext highlighter-rouge">arch-chroot /mnt/root</code> and do the remaining parts, until before “Network configuration”</li>
  <li>For “Network configuration”, the hostname could be unique for each system, or same, depending on your need; for network manager, I’d recommend just use <code class="language-plaintext highlighter-rouge">systemd-networkd</code>, so:
    <ul>
      <li>Enable networkd and resolved: <code class="language-plaintext highlighter-rouge">systemctl enable systemd-{network,resolve}d</code></li>
      <li>Quit from chroot</li>
      <li>Copy archiso’s network config files: <code class="language-plaintext highlighter-rouge">cp -rva /etc/systemd/network/* /mnt/root/etc/systemd/network/</code></li>
      <li>Re-link resolv.conf <code class="language-plaintext highlighter-rouge">ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/root/etc/resolv.conf</code></li>
      <li>Re-enter chroot</li>
    </ul>
  </li>
  <li>Skip “Initramfs”, we’re using <code class="language-plaintext highlighter-rouge">booster</code> instead of the default <code class="language-plaintext highlighter-rouge">mkinitcpio</code>, and the universal initramfs was already created</li>
  <li>For “Boot Loader”, we would install grub, the package was already installed into root in earlier bootstrap steps, we only need to install it to bootable:
    <ol>
      <li>Install as removable EFI, note we also specify <code class="language-plaintext highlighter-rouge">--boot-directory /efi</code>, so <code class="language-plaintext highlighter-rouge">grub</code> modules and first-stage config are saved and loaded from there. We would only want each system’s <code class="language-plaintext highlighter-rouge">/boot</code> to store there boot config
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> grub-install --removable --efi-directory /efi --boot-directory /efi
</code></pre></div>        </div>
      </li>
      <li>Install to MBR, similarly note we also specify <code class="language-plaintext highlighter-rouge">--boot-directory /efi</code>
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> grub-install --target i386-pc --boot-directory /efi /dev/[drive]
</code></pre></div>        </div>
      </li>
      <li>Hack/fix <code class="language-plaintext highlighter-rouge">/etc/grub.d/10_linux</code> so it would prefer booster initramfs (without this, booster initramfs would be hidden in a submenu) and ro root; if you’re not using booster only or you don’t require ro root on boot, you can skip this:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> From 9ad850b2b8842bb673313be08f6a5af66cdf12ea Mon Sep 17 00:00:00 2001
 From: Guoxin Pu &lt;pugokushin@gmail.com&gt;
 Date: Thu, 13 Nov 2025 15:52:27 +0800
 Subject: [PATCH] use booster as main initramfs and prefer ro

 ---
 10_linux | 26 ++------------------------
 1 file changed, 2 insertions(+), 24 deletions(-)

 diff --git a/10_linux b/10_linux
 index e16cea8..3ce3a9d 100755
 --- a/10_linux
 +++ b/10_linux
 @@ -147,7 +147,7 @@ linux_entry ()
   message="$(gettext_printf "Loading Linux %s ..." ${version})"
   sed "s/^/$submenu_indentation/" &lt;&lt; EOF
         echo    '$(echo "$message" | grub_quote)'
 -       linux   ${rel_dirname}/${basename} root=${linux_root_device_thisversion} rw ${args}
 +       linux   ${rel_dirname}/${basename} root=${linux_root_device_thisversion} ro ${args}
 EOF
   if test -n "${initrd}" ; then
     # TRANSLATORS: ramdisk isn't identifier. Should be translated.
 @@ -227,7 +227,7 @@ for linux in ${reverse_sorted_list}; do
   done

   initrd_real=
 -  for i in "initrd.img-${version}" "initrd-${version}.img" \
 +  for i in "booster-${version}.img" "initrd.img-${version}" "initrd-${version}.img" \
           "initrd-${alt_version}.img.old" "initrd-${version}.gz" \
           "initrd-${alt_version}.gz.old" "initrd-${version}" \
           "initramfs-${version}.img" "initramfs-${alt_version}.img.old" \
 @@ -304,28 +304,6 @@ for linux in ${reverse_sorted_list}; do
   linux_entry "${OS}" "${version}" advanced \
               "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"

 -  if test -e "${dirname}/initramfs-${version}-fallback.img" ; then
 -    initrd="${initrd_early} initramfs-${version}-fallback.img"
 -
 -    if test -n "${initrd}" ; then
 -      gettext_printf "Found fallback initrd image(s) in %s:%s\n" "${dirname}" "${initrd_extra} ${initrd}" &gt;&amp;2
 -    fi
 -
 -    linux_entry "${OS}" "${version}" fallback \
 -                "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"
 -  fi
 -
 -  if test -e "${dirname}/booster-${version}.img" ; then
 -    initrd="${initrd_early} booster-${version}.img"
 -
 -    if test -n "${initrd}" ; then
 -      gettext_printf "Found booster initrd image(s) in %s:%s\n" "${dirname}" "${initrd_extra} ${initrd}" &gt;&amp;2
 -    fi
 -
 -    linux_entry "${OS}" "${version}" booster \
 -                "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"
 -  fi
 -
   if [ "x${GRUB_DISABLE_RECOVERY}" != "xtrue" ]; then
     linux_entry "${OS}" "${version}" recovery \
                 "${GRUB_CMDLINE_LINUX_RECOVERY} ${GRUB_CMDLINE_LINUX}"
 --
 2.51.2
</code></pre></div>        </div>
      </li>
      <li>Generate <code class="language-plaintext highlighter-rouge">grub.cfg</code> for Arch Linux as how you do it on a normal installation:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mkdir /boot/grub
 grub-mkconfig -o /boot/grub/grub.cfg
</code></pre></div>        </div>
        <p>Note this would not be the first config Grub loads, but rather just a “to-be-included” config.</p>
      </li>
      <li>Let’s write a real, outer config for Grub to achieve menu logic by <code class="language-plaintext highlighter-rouge">vim /efi/grub/grub.cfg</code> with content like following:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> search.fs_uuid 6894094c-a75e-4f1a-b228-283faf7bf003 root hd0,gpt2
 terminal_input console
 terminal_output console
 set suffix='@/boot/grub/grub.cfg'
 menuentry 'Arch Linux (x86_64)' {
         configfile ($root)/arch-x86_64/$suffix
 }
</code></pre></div>        </div>
      </li>
    </ol>
  </li>
  <li>Finalize the installation, umount everything and poweroff</li>
</ol>

<p>After the above steps we shall have a UEFI + legacy bootable, you can boot on different machines to validate it, the boot menu shall look like this on both UEFI and legacy:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
                         GNU GRUB  version 2:2.14rc1-2

 ┌────────────────────────────────────────────────────────────────────────────┐
 │*Arch Linux (x86_64)                                                        │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │ 
 └────────────────────────────────────────────────────────────────────────────┘

      Use the ▲ and ▼ keys to select which entry is highlighted.          
      Press enter to boot the selected OS, `e' to edit the commands       
      before booting or `c' for a command-line.                           
                                                                             
</code></pre></div></div>

<p>And after pressing Enter it’s the same old Arch Linux Grub menu as always.</p>

<p>While we’re at it, you can of course add timeout, decoration, etc to the outer menu. I’ll stick with the simple look and continue.</p>

<h2 id="after-installation-for-arch-linux-x86_64">After-installation for Arch Linux x86_64</h2>

<p>This part is mostly about quality of life improvement and can be skipped. If you want to follow, boot into the installed Arch Linux.</p>

<h3 id="sshd-config--key">sshd config + key</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pacman -S openssh
systemctl enable --now sshd
</code></pre></div></div>

<p>Verify that sshd pubkeys are generated successfully:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># ls /etc/ssh
moduli	ssh_config  ssh_config.d  sshd_config  sshd_config.d  ssh_host_ecdsa_key  ssh_host_ecdsa_key.pub  ssh_host_ed25519_key	ssh_host_ed25519_key.pub  ssh_host_rsa_key  ssh_host_rsa_key.pub
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># systemctl status sshdgenkeys
○ sshdgenkeys.service - SSH Key Generation
     Loaded: loaded (/usr/lib/systemd/system/sshdgenkeys.service; disabled; preset: disabled)
     Active: inactive (dead) since Thu 2025-11-13 16:26:26 CST; 1min 51s ago
 Invocation: ef2323bd8d4e4007be02a0e3208f3c31
    Process: 578 ExecStart=/usr/bin/ssh-keygen -A (code=exited, status=0/SUCCESS)
   Main PID: 578 (code=exited, status=0/SUCCESS)
   Mem peak: 1.7M
        CPU: 84ms

Nov 13 16:26:25 dud systemd[1]: Starting SSH Key Generation...
Nov 13 16:26:26 dud ssh-keygen[578]: ssh-keygen: generating new host keys: RSA ECDSA ED25519
Nov 13 16:26:26 dud systemd[1]: sshdgenkeys.service: Deactivated successfully.
Nov 13 16:26:26 dud systemd[1]: Finished SSH Key Generation.
</code></pre></div></div>

<p>Do required sshd_config modification as needed:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vim /etc/ssh/sshd_config
</code></pre></div></div>

<ul>
  <li>I would replace <code class="language-plaintext highlighter-rouge">AuthorizedKeysFile .ssh/authorized_keys</code> -&gt; <code class="language-plaintext highlighter-rouge">AuthorizedKeysFile  /etc/ssh/authorized_keys/%u</code>, so no one can add their SSH pubkey except root (me); and I would prepare this folder as needed</li>
  <li>I would uncomment <code class="language-plaintext highlighter-rouge">#PasswordAuthentication</code> and set <code class="language-plaintext highlighter-rouge">PasswordAuthentication no</code> so password login is disabled</li>
</ul>

<p>After the modification restart sshd so the changes take effect:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl restart sshd
</code></pre></div></div>

<h3 id="snapshot-on-boot">snapshot on boot</h3>

<p>For the setup I would want after-boot snapshots to be taken, so install <code class="language-plaintext highlighter-rouge">snapper</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pacman -S snapper
</code></pre></div></div>

<p>Create configs and enable needed services</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vim /etc/snapper/configs/root
vim /etc/snapper/configs/home
</code></pre></div></div>

<p>The content shall look like the following:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SUBVOLUME="/"
FSTYPE="btrfs"
QGROUP=""
SPACE_LIMIT="0.5"
FREE_LIMIT="0.2"
ALLOW_USERS=""
ALLOW_GROUPS=""
SYNC_ACL="no"
BACKGROUND_COMPARISON="yes"
NUMBER_CLEANUP="yes"
NUMBER_MIN_AGE="1800"
NUMBER_LIMIT="50"
NUMBER_LIMIT_IMPORTANT="10"
EMPTY_PRE_POST_CLEANUP="yes"
EMPTY_PRE_POST_MIN_AGE="1800"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SUBVOLUME="/home"
FSTYPE="btrfs"
QGROUP=""
SPACE_LIMIT="0.5"
FREE_LIMIT="0.2"
ALLOW_USERS=""
ALLOW_GROUPS=""
SYNC_ACL="no"
BACKGROUND_COMPARISON="yes"
NUMBER_CLEANUP="yes"
NUMBER_MIN_AGE="1800"
NUMBER_LIMIT="50"
NUMBER_LIMIT_IMPORTANT="10"
EMPTY_PRE_POST_CLEANUP="yes"
EMPTY_PRE_POST_MIN_AGE="1800"
</code></pre></div></div>

<p>And edit the global config to enable these profiles:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vim /etc/conf.d/snapper
</code></pre></div></div>

<p>With the following line:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SNAPPER_CONFIGS="root home"
</code></pre></div></div>

<p>By default <code class="language-plaintext highlighter-rouge">snapper-boot</code> only snapshots root, so modify the unit:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl edit snapper-boot
</code></pre></div></div>

<p>The result shall look like this (ExecStart is appended after the original ExecStarted):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; cat /etc/systemd/system/snapper-boot.service.d/override.conf
[Service]
ExecStart=/usr/bin/snapper --config home create --cleanup-algorithm number --description "boot"
</code></pre></div></div>

<p>Then let’s enable needed units:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl enable --now snapper-{boot,cleanup}.timer
</code></pre></div></div>

<h2 id="installing-debian-x86_64">Installing Debian x86_64</h2>

<p>Let’s do this on Arch Linux x86_64 with <code class="language-plaintext highlighter-rouge">debootstrap</code> and <code class="language-plaintext highlighter-rouge">arch-chroot</code></p>

<p>Of course the tools shall be installed first:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pacman -S debootstrap arch-install-scripts
</code></pre></div></div>

<p>The installation goes similarly as Arch, but keep the following points in mind:</p>
<ol>
  <li>After chroot, do <code class="language-plaintext highlighter-rouge">export PATH=</code> with missing <code class="language-plaintext highlighter-rouge">/sbin</code> parts, whole command in later steps</li>
  <li>Do not install any boot manager! The only system here that has a boot manager installed is Arch x86_64.</li>
</ol>

<p>The steps are as follows:</p>

<ol>
  <li>Similarly, mount root and nocow subvol first:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount -o subvol=debian-x86_64/@ --mkdir /dev/[drive]3 /mnt/root
 mount -o subvol=debian-x86_64/+nocow --mkdir /dev/[drive]3 /mnt/debian-x86_64+nocow
</code></pre></div>    </div>
    <p>Then duplicate fstab:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mkdir /mnt/root/etc
 sed 's/arch/debian/g' /etc/fstab &gt; /mnt/root/etc/fstab
</code></pre></div>    </div>
    <p>Then mount everything remaining up:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount --all --fstab /mnt/root/etc/fstab --target-prefix /mnt/root --mkdir
</code></pre></div>    </div>

    <p>A <code class="language-plaintext highlighter-rouge">lsblk</code> shall look like following now:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> # lsblk
 NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
 vda    254:0    0   128G  0 disk
 ├─vda1 254:1    0   100M  0 part /mnt/root/efi
 │                                /efi
 ├─vda2 254:2    0     1M  0 part
 └─vda3 254:3    0 127.9G  0 part /mnt/root/var/tmp
                                  /mnt/root/var/spool
                                  /mnt/root/var/log
                                  /mnt/root/var/cache
                                  /mnt/root/mnt/debian-x86_64+nocow
                                  /mnt/root/home/.snapshots
                                  /mnt/root/home
                                  /mnt/root/.snapshots
                                  /mnt/debian-x86_64+nocow
                                  /mnt/root
                                  /var/tmp
                                  /var/spool
                                  /var/log
                                  /home/.snapshots
                                  /var/cache
                                  /mnt/arch-x86_64+nocow
                                  /home
                                  /.snapshots
                                  /
</code></pre></div>    </div>
  </li>
  <li>Do <code class="language-plaintext highlighter-rouge">debootstrap</code> into the root
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> debootstrap trixie /mnt/root http://[mirror_link]
</code></pre></div>    </div>
  </li>
  <li>chroot into <code class="language-plaintext highlighter-rouge">/mnt/root</code>, and set <code class="language-plaintext highlighter-rouge">PATH</code>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> arch-chroot /mnt/root
</code></pre></div>    </div>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
</code></pre></div>    </div>
    <p>The PATH needs to be set as <code class="language-plaintext highlighter-rouge">arch-chroot</code> keeps PATH from Arch, which lacks <code class="language-plaintext highlighter-rouge">sbin</code>, but on Debian <code class="language-plaintext highlighter-rouge">sbin</code> is a seperate folder than <code class="language-plaintext highlighter-rouge">bin</code></p>
  </li>
  <li>Install a few missing packages
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> apt update
 apt install vim locales systemd-timesyncd btrfs-progs dosfstools linux-image-amd64 amd64-microcode intel-microcode firmware-linux
</code></pre></div>    </div>
  </li>
  <li>Do timezone, locale, hostname setup just like how you did it in Arch</li>
  <li>For network manager, similarly, I’d recommend <code class="language-plaintext highlighter-rouge">systemd-networkd</code> + <code class="language-plaintext highlighter-rouge">systemd-resolved</code> pair, but on Debian <code class="language-plaintext highlighter-rouge">resolved</code> needs to be installed seperately:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> apt install systemd-resolved
</code></pre></div>    </div>
  </li>
  <li>Exit from chroot and borrow host Network configuration and re-link resolv.conf just like how we did for Arch x86_64 above, then re-enter chroot</li>
  <li>Now, the Grub, we only need Debian to generate <code class="language-plaintext highlighter-rouge">grub.cfg</code>, but definitely not intalling and maintaining the actual Grub boot manager, so install only the system integration part and prepare the folder manually:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> apt install grub2-common
 mkdir /boot/grub
 vim /etc/default/grub
</code></pre></div>    </div>
    <p>With this setup there would be no pre-configured <code class="language-plaintext highlighter-rouge">grub</code> config, so use the following as a starting point:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> GRUB_DEFAULT=0
 GRUB_TIMEOUT=1
 GRUB_DISTRIBUTOR=`( . /etc/os-release &amp;&amp; echo ${NAME} )`
 GRUB_CMDLINE_LINUX_DEFAULT="audit=0"
 GRUB_CMDLINE_LINUX=""
 GRUB_TERMINAL=console
</code></pre></div>    </div>
    <p>Remember to re-generate the one included by our outer grub</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> update-grub
</code></pre></div>    </div>
  </li>
  <li>Exit from chroot and update our outer Grub config <code class="language-plaintext highlighter-rouge">/efi/grub/grub.cfg</code> to include a new menuentry:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> menuentry 'Debian (x86_64)' {
     configfile ($root)/debian-x86_64/$suffix
 }
</code></pre></div>    </div>
  </li>
  <li>Finalize the installation, umount everything and poweroff</li>
</ol>

<p>After the above steps we shall have a UEFI + legacy bootable Arch Linux + Debian installation, you can boot on different machines to validate it, the boot menu shall look like this on both UEFI and legacy:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
                         GNU GRUB  version 2:2.14rc1-2

 ┌────────────────────────────────────────────────────────────────────────────┐
 │*Arch Linux (x86_64)                                                        │
 │ Debian (x86_64)                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │ 
 └────────────────────────────────────────────────────────────────────────────┘

      Use the ▲ and ▼ keys to select which entry is highlighted.          
      Press enter to boot the selected OS, `e' to edit the commands       
      before booting or `c' for a command-line.                           
                                                                             
</code></pre></div></div>

<p>And after selecting Debian and pressing Enter it’s the same old Debian Grub menu as always.</p>

<h2 id="after-installation-for-debian-x86_64">After-installation for Debian x86_64</h2>

<p>This part is mostly about quality of life improvement and can be skipped. If you want to follow, boot into the installed Debian and do similar things as in <a href="#after-installation-for-arch-linux-x86_64">After-installation for Arch Linux x86_64</a>, but with the following differences:</p>

<ol>
  <li>If you want to share hostname, it’s better to share host keys, i.e. <code class="language-plaintext highlighter-rouge">/etc/ssh/ssh_host_*_key{,.pub}</code>, so clients would not complain about host key differing</li>
  <li>For snapper, Debian comes with all units pre-enabled, disable the timeline as we want only on-boot: <code class="language-plaintext highlighter-rouge">systemctl disable --now snapper-timeline.timer</code>; and note the snapper config folder is the same but the global config is at <code class="language-plaintext highlighter-rouge">/etc/default/snapper</code></li>
  <li>While <code class="language-plaintext highlighter-rouge">debootstrap</code> still prepares an old-style APT <code class="language-plaintext highlighter-rouge">sources.list</code>, I recommend to migrate to new APT config style:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> rm /etc/apt/sources.list
 vim /etc/apt/sources.list.d/debian.sources
</code></pre></div>    </div>
    <p>With content like the following:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Types: deb
 URIs: http://[mirror]/debian/
 Suites: trixie trixie-updates
 Components: main contrib non-free non-free-firmware
 Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg

 Types: deb
 URIs: http://[mirror]/debian-security/
 Suites: trixie-security
 Components: main contrib non-free non-free-firmware
 Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg

 Types: deb
 URIs: http://[mirror]/debian/
 Suites: trixie-backports
 Components: main contrib non-free non-free-firmware
 Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
</code></pre></div>    </div>
  </li>
  <li>It’s good if you remember that we did not configure initramfs-tools to create generic initramfs. In most cases it comes with pre-configured <code class="language-plaintext highlighter-rouge">MODULES=most</code> and you would not need to modify that, only with <code class="language-plaintext highlighter-rouge">debian-install</code> could this be set to <code class="language-plaintext highlighter-rouge">MODULES=deps</code>. To verify, <code class="language-plaintext highlighter-rouge">vim /etc/initramfs-tools/initramfs.conf</code> and check <code class="language-plaintext highlighter-rouge">MODULES</code>. If it’s <code class="language-plaintext highlighter-rouge">MODULES=deps</code> then modify it, save, and run <code class="language-plaintext highlighter-rouge">update-initramfs -u</code></li>
  <li>There’re many firmware not installed by <code class="language-plaintext highlighter-rouge">firmware-linux</code> meta package, in most cases these are not needed, if you really really need all of them installed:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> apt install $(for i in $(apt-cache search '^firmware-' | cut -d ' ' -f 1); do dpkg-query -W -f='${Status}' $i &amp;&gt;/dev/null || echo $i; done | grep -v installer)
</code></pre></div>    </div>
  </li>
</ol>

<h2 id="installing-arch-linux-arm-aarch64">Installing Arch Linux ARM aarch64</h2>

<p>Let’s do this on Arch Linux x86_64</p>

<ol>
  <li>Install dependencies first
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> pacman -S qemu-user-static-binfmt arch-install-scripts
</code></pre></div>    </div>
  </li>
  <li>Duplicate host pacman config and do necessary modification
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> cp /etc/pacman.conf pacman-alarm.conf
 vim pacman-alarm.conf
</code></pre></div>    </div>
    <ol>
      <li>Set <code class="language-plaintext highlighter-rouge">Architecture = aarch64</code> instead of <code class="language-plaintext highlighter-rouge">auto</code></li>
      <li>Set repo <code class="language-plaintext highlighter-rouge">Inlucde = mirrorlist-alarm</code> instead of <code class="language-plaintext highlighter-rouge">/etc/pacman.d/mirrorlist</code></li>
      <li>Temporarily set <code class="language-plaintext highlighter-rouge">SigLevel = Never</code> as we don’t have ALARM keyring on Arch and adding those to Arch host would mess up the host, pacakges can later be re-verified once we have installed ALARM</li>
      <li>Add <a href="https://github.com/7Ji/archrepo">my repo</a>, we would use <code class="language-plaintext highlighter-rouge">linux-aarch64-7ji</code> as kernel, instead of ALARM’s official <code class="language-plaintext highlighter-rouge">linux-aarch64</code>, the latter misses some drivers built-in and would not boot on some of my SBCs and it has max CPUs set to a small number so would not work nicely on VM either, and the worst is it has some naive hooks to always expect <code class="language-plaintext highlighter-rouge">mkinitcpio</code> instead of any other initramfs maker.
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> [7Ji]
 Include = mirrorlist-3rdparty
</code></pre></div>        </div>
      </li>
    </ol>
  </li>
  <li>Create mirrorlist for alarm
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> vim mirrorlist-alarm
</code></pre></div>    </div>
    <p>With server:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Server = http://[mirror]/archlinuxarm/$arch/$repo
</code></pre></div>    </div>
  </li>
  <li>Create mirrorlist for 3rdparty repo
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> vim mirrorlist-3rdparty
</code></pre></div>    </div>
    <p>With server:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Server = http://[mirror]/$repo/$arch
</code></pre></div>    </div>
  </li>
  <li>Mount the root tree
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount -o subvol=alarm-aarch64/@ --mkdir /dev/[drive]3 /mnt/root
 mount -o subvol=alarm-aarch64/+nocow --mkdir /dev/[drive]3 /mnt/alarm-aarch64+nocow
</code></pre></div>    </div>
    <p>Then duplicate fstab:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mkdir /mnt/root/etc
 sed 's/arch-x86_64/alarm-aarch64/g' /etc/fstab &gt; /mnt/root/etc/fstab
</code></pre></div>    </div>
    <p>Then mount everything remaining up:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mount --all --fstab /mnt/root/etc/fstab --target-prefix /mnt/root --mkdir
</code></pre></div>    </div>

    <p>A <code class="language-plaintext highlighter-rouge">lsblk</code> shall look like following now:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> # lsblk
 NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
 vda    254:0    0   128G  0 disk
 ├─vda1 254:1    0   100M  0 part /mnt/root/efi
 │                                /efi
 ├─vda2 254:2    0     1M  0 part
 └─vda3 254:3    0 127.9G  0 part /mnt/root/var/tmp
                                  /mnt/root/var/spool
                                  /mnt/root/var/log
                                  /mnt/root/var/cache
                                  /mnt/root/mnt/alarm-aarch64+nocow
                                  /mnt/root/home/.snapshots
                                  /mnt/root/home
                                  /mnt/root/.snapshots
                                  /mnt/alarm-aarch64+nocow
                                  /mnt/root
                                  /var/tmp
                                  /var/spool
                                  /var/log
                                  /home/.snapshots
                                  /var/cache
                                  /mnt/arch-x86_64+nocow
                                  /home
                                  /.snapshots
                                  /
</code></pre></div>    </div>
  </li>
  <li>Do <code class="language-plaintext highlighter-rouge">pacstrap</code> into the root
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> echo 'universal: true' &gt; /mnt/root/etc/booster.yaml
 pacstrap -C pacman-alarm.conf -K -M /mnt/root base booster linux-aarch64-7ji linux-firmware btrfs-progs dosfstools grub vim sudo archlinuxarm-keyring 7ji-keyring
</code></pre></div>    </div>
  </li>
  <li>Re-edit (or duplicate from CWD) the pacman configs and mirrorlist under <code class="language-plaintext highlighter-rouge">/mnt/root/etc</code>, as now they come the official package, remember to add 7Ji repo
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> vim /mnt/root/etc/pacman.conf
 vim /mnt/root/etc/pacman.d/mirrorlist
 vim /mnt/root/etc/pacman.d/mirrorlist-3rdparty
</code></pre></div>    </div>
  </li>
  <li>Chroot into the target and confirm we’re running as aarch64
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> arch-chroot /mnt/root
</code></pre></div>    </div>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> uname -m
</code></pre></div>    </div>
    <p>Note: as we’re using qemu-static, the stdin/out is technically not directly attached to our terminal, so text editing could be a pain. If text editing is needed, do it from another SSH session from host under <code class="language-plaintext highlighter-rouge">/mnt/root</code> instead so VIM and Nano could correctly write to terminal.</p>
  </li>
  <li>Initalize the keyring
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> pacman-key --init
 pacman-key --populate
</code></pre></div>    </div>
  </li>
  <li>Let’s actually verify the packages, now that we’ve nitialized the keyring
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pacman -Sy --downloadonly $(sed -n '/^%NAME%/{n;p}' /var/lib/pacman/local/*/desc)
</code></pre></div>    </div>
  </li>
  <li>Continue and finish the setup just like how we did in Arch Linux, until before the boot manager</li>
  <li>Similar to Arch Linux x86_64, let’s install and hack Grub
    <ol>
      <li>Install as removable EFI, note we also specify <code class="language-plaintext highlighter-rouge">--boot-directory /efi</code>, so <code class="language-plaintext highlighter-rouge">grub</code> modules and first-stage config are saved and loaded from there. We would only want each system’s <code class="language-plaintext highlighter-rouge">/boot</code> to store there boot config
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> grub-install --removable --efi-directory /efi --boot-directory /efi
</code></pre></div>        </div>
      </li>
      <li>Hack/fix <code class="language-plaintext highlighter-rouge">/etc/grub.d/10_linux</code> so it would prefer booster initramfs (without this, booster initramfs would be hidden in a submenu) and ro root; if you’re not using booster only or you don’t require ro root on boot, you can skip this:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> From 9ad850b2b8842bb673313be08f6a5af66cdf12ea Mon Sep 17 00:00:00 2001
 From: Guoxin Pu &lt;pugokushin@gmail.com&gt;
 Date: Thu, 13 Nov 2025 15:52:27 +0800
 Subject: [PATCH] use booster as main initramfs and prefer ro

 ---
 10_linux | 26 ++------------------------
 1 file changed, 2 insertions(+), 24 deletions(-)

 diff --git a/10_linux b/10_linux
 index e16cea8..3ce3a9d 100755
 --- a/10_linux
 +++ b/10_linux
 @@ -147,7 +147,7 @@ linux_entry ()
   message="$(gettext_printf "Loading Linux %s ..." ${version})"
   sed "s/^/$submenu_indentation/" &lt;&lt; EOF
         echo    '$(echo "$message" | grub_quote)'
 -       linux   ${rel_dirname}/${basename} root=${linux_root_device_thisversion} rw ${args}
 +       linux   ${rel_dirname}/${basename} root=${linux_root_device_thisversion} ro ${args}
 EOF
   if test -n "${initrd}" ; then
     # TRANSLATORS: ramdisk isn't identifier. Should be translated.
 @@ -227,7 +227,7 @@ for linux in ${reverse_sorted_list}; do
   done

   initrd_real=
 -  for i in "initrd.img-${version}" "initrd-${version}.img" \
 +  for i in "booster-${version}.img" "initrd.img-${version}" "initrd-${version}.img" \
           "initrd-${alt_version}.img.old" "initrd-${version}.gz" \
           "initrd-${alt_version}.gz.old" "initrd-${version}" \
           "initramfs-${version}.img" "initramfs-${alt_version}.img.old" \
 @@ -304,28 +304,6 @@ for linux in ${reverse_sorted_list}; do
   linux_entry "${OS}" "${version}" advanced \
               "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"

 -  if test -e "${dirname}/initramfs-${version}-fallback.img" ; then
 -    initrd="${initrd_early} initramfs-${version}-fallback.img"
 -
 -    if test -n "${initrd}" ; then
 -      gettext_printf "Found fallback initrd image(s) in %s:%s\n" "${dirname}" "${initrd_extra} ${initrd}" &gt;&amp;2
 -    fi
 -
 -    linux_entry "${OS}" "${version}" fallback \
 -                "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"
 -  fi
 -
 -  if test -e "${dirname}/booster-${version}.img" ; then
 -    initrd="${initrd_early} booster-${version}.img"
 -
 -    if test -n "${initrd}" ; then
 -      gettext_printf "Found booster initrd image(s) in %s:%s\n" "${dirname}" "${initrd_extra} ${initrd}" &gt;&amp;2
 -    fi
 -
 -    linux_entry "${OS}" "${version}" booster \
 -                "${GRUB_CMDLINE_LINUX} ${GRUB_CMDLINE_LINUX_DEFAULT}"
 -  fi
 -
   if [ "x${GRUB_DISABLE_RECOVERY}" != "xtrue" ]; then
     linux_entry "${OS}" "${version}" recovery \
                 "${GRUB_CMDLINE_LINUX_RECOVERY} ${GRUB_CMDLINE_LINUX}"
 --
 2.51.2
</code></pre></div>        </div>
      </li>
      <li>Generate <code class="language-plaintext highlighter-rouge">grub.cfg</code> for Arch Linux ALARM as how you do it on a normal installation:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> mkdir /boot/grub
 grub-mkconfig -o /boot/grub/grub.cfg
</code></pre></div>        </div>
        <p>Note this would not be the first config Grub loads, but rather just a “to-be-included” config.</p>
      </li>
      <li>Add a menuentry in outer Grub config:
        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> menuentry 'Arch Linux ARM (aarch64)' {
     configfile ($root)/alarm-aarch64/$suffix
 }
</code></pre></div>        </div>
      </li>
    </ol>
  </li>
  <li>Finalize the installation, umount everything and poweroff</li>
</ol>

<p>The drive should now work on an aarch64 VM (pure UEFI), but not on real hardware (U-boot faking UEFI), due to missing DTB. Verify it on VM first before trying on real hardware. (Remeber to disable Secure Boot first)</p>

<p>The Grub menu shall look like the following in VM:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
                         GNU GRUB  version 2:2.14rc1-2

 ┌────────────────────────────────────────────────────────────────────────────┐
 │*Arch Linux (x86_64)                                                        │
 │ Debian (x86_64)                                                            │
 │ Arch Linux ARM (aarch64)                                                   │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │ 
 └────────────────────────────────────────────────────────────────────────────┘

      Use the ▲ and ▼ keys to select which entry is highlighted.          
      Press enter to boot the selected OS, `e' to edit the commands       
      before booting or `c' for a command-line.                           
                                                                             
</code></pre></div></div>

<p>And after selecting Arch Linux ARM and pressing Enter it’s the same old Arch Grub menu (well ALARM didn’t bother to modify the look) as always.</p>

<p>Now, for U-Boot to correctly work, U-Boot itself needs to load the specific DTB for device. We don’t want Grub to load DTB from root AFTER U-Boot loads Grub, as the device must be fully functional at the time Grub wants to open the Btrfs root, and that’s too late.</p>

<p>In modern-day U-Boot, there would be these built-in variables:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">efi_dtb_prefixes=/ /dtb/ /dtb/current/</code> -&gt; Same across builds</li>
  <li><code class="language-plaintext highlighter-rouge">fdtfile=amlogic/meson-sm1-bananapi-m5.dtb</code> -&gt; Unique for each board</li>
  <li><code class="language-plaintext highlighter-rouge">scan_dev_for_efi=setenv efi_fdtfile ${fdtfile}; for prefix in ${efi_dtb_prefixes}; do if test -e ${devtype} ${devnum}:${distro_bootpart} ${prefix}${efi_fdtfile}; then run load_efi_dtb; fi;done;run boot_efi_bootmgr;if test -e ${devtype} ${devnum}:${distro_bootpart} efi/boot/bootaa64.efi; then echo Found EFI removable media binary efi/boot/bootaa64.efi; run boot_efi_binary; echo EFI LOAD FAILED: continuing...; fi; setenv efi_fdtfile</code> -&gt; Same across builds</li>
</ul>

<p>So take my BPI-M5 for example, the place for the DTB could be:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">ESP/amlogic/meson-sm1-bananapi-m5.dtb</code></li>
  <li><code class="language-plaintext highlighter-rouge">ESP/dtb/amlogic/meson-sm1-bananapi-m5.dtb</code></li>
  <li><code class="language-plaintext highlighter-rouge">ESP/dtb/current/amlogic/meson-sm1-bananapi-m5.dtb</code></li>
</ul>

<p>I’ll pick the second one</p>

<p>For this, we needs to copy some DTBs to “ESP”, usually in U-Boot <code class="language-plaintext highlighter-rouge">efi_dtb_prefixes=/ /dtb/ /dtb/current/</code>, so let’s pick <code class="language-plaintext highlighter-rouge">/dtb/</code> so they can be loaded by U-Boot as early as possible</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cp -rva /mnt/root/boot/dtbs/linux-aarch64-7ji /mnt/root/efi/dtb
</code></pre></div></div>

<p>And booting on real hardware should now be OK. I’ve tested this on my BananaPi BPi-M5, OrangePi 5, Orange Pi 5 Plus and they all work seamlessly.</p>

<p>Note: as you may have tried and realized, even if you did not place the DTB, boards could still boot, but in those cases it is the U-Boot’s built-in DTB that’s used, and for newer kernels this could bring some problems.</p>

<p>Note also: by doing this we’ve locked the DTB to the one provided by <code class="language-plaintext highlighter-rouge">linux-aarch64-7ji</code> kernel, which is a stable-as-new-as-possible kernel, <strong>not only for Arch Linux ARM, but also for the latter Debian installation</strong>, using new DTBs on old kernels generally wouldn’t bring much trouble, unlike other way around.</p>

<h2 id="installing-debian-aarch64">Installing Debian aarch64</h2>

<p>Do this in Arch Linux ARM aarch64, just similar to how we installed Debian x86_64 from Arch Linux x86_64. Just remember that still we would not want Grub the boot manager installed here, but rather Debian should only install <code class="language-plaintext highlighter-rouge">grub2-common</code> to auto-update its sub gurb.cfg</p>

<p>The final Grub menu shall look like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
                         GNU GRUB  version 2:2.14rc1-2

 ┌────────────────────────────────────────────────────────────────────────────┐
 │*Arch Linux (x86_64)                                                        │
 │ Debian (x86_64)                                                            │
 │ Arch Linux ARM (aarch64)                                                   │
 │ Debian (aarch64)                                                           │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │
 │                                                                            │ 
 └────────────────────────────────────────────────────────────────────────────┘

      Use the ▲ and ▼ keys to select which entry is highlighted.          
      Press enter to boot the selected OS, `e' to edit the commands       
      before booting or `c' for a command-line.                           
                                                                             
</code></pre></div></div>

<h2 id="after-all-installation">After all installation</h2>

<p>Now it’s good time to do modification, to e.g. decorate the outer menu, reorder the menu, add more entries, etc.</p>

<p>Also note that I did not set timeout, for that booting default x86_64 on aarch64 is plainly wrong, and this blog was written as simple to follow as posisble. This could be improved by either one of the following ways:</p>
<ul>
  <li>Use seperate –boot-directory e.g. <code class="language-plaintext highlighter-rouge">/efi/x86_64</code> and <code class="language-plaintext highlighter-rouge">/efi/aarch64</code> and maintain sepearate outer grub.cfg for each architecture</li>
  <li>Use <code class="language-plaintext highlighter-rouge">$grub_platform</code> variable and <code class="language-plaintext highlighter-rouge">$grub_cpu</code> to determine the current platform. If you’d want to try, the syntax shall look like this:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  set timeout 1
  if [ "${grub_cpu}" = 'x86_64' -a "${grub_platform}" = 'efi' ]; then
      # x86_64 UEFI Secure Boot only
      menuentry ...
  elif [ "${grub_cpu}" = 'x86_64' -o "${grub_cpu}" = 'i386']; then
      # x86_64 UEFI plain, or legacy
      menuentry ...
  elif [ "${grub_cpu}" = 'arm64' ]; then
      if [ "${grub_platform}" = 'efi' ]; then
          # arm64 UEFI Secure Boot only
          menuentry ...
      fi
      # arm64 plain
      menuentry ...
  fi
</code></pre></div>    </div>
  </li>
</ul>]]></content><author><name></name></author><category term="booting" /><summary type="html"><![CDATA[Recently I needed to do offline OS maintainance work on quite a few of my devices, for which I used Ventoy + archiso on x86_64 for Debian 13 / Arch, and ALARM os drive on aarch64 for Debain 13 / ALARM. For which I find archiso more and more annoying as I had to re-do a lot of initial setups.]]></summary></entry><entry><title type="html">Booster’s multi-device Btrfs race condition</title><link href="https://7ji.github.io/booting/2025/06/30/booster-mutli-device-btrfs-race-condition.html" rel="alternate" type="text/html" title="Booster’s multi-device Btrfs race condition" /><published>2025-06-30T03:50:00+00:00</published><updated>2025-06-30T03:50:00+00:00</updated><id>https://7ji.github.io/booting/2025/06/30/booster-mutli-device-btrfs-race-condition</id><content type="html" xml:base="https://7ji.github.io/booting/2025/06/30/booster-mutli-device-btrfs-race-condition.html"><![CDATA[<p><em>Repost of booster <a href="https://github.com/anatol/booster/pull/299">Pull Request #299</a> created by myself</em>:</p>

<p>In a recent change to kernel pacakage the module for Btrfs became built-in instead of as-module: <a href="https://gitlab.archlinux.org/archlinux/packaging/packages/linux/-/commit/a7e2a17f9c0e55937ea3e18c4d5b905a8e4f8047">diff</a></p>

<p>This reveals a race condition (in both kernel and booster) which seemed to have been “worked around” in the past but in reality not, for multi-device Btrfs:</p>
<ul>
  <li>Booster did not check the return status of <code class="language-plaintext highlighter-rouge">BTRFS_IOC_DEVICES_READY</code> and it mounts every Btrfs sub-device whether they’re actually ready or not.</li>
  <li>The kernel considers a Btrfs filesytem being “used” while it is being mounted from sub-device A and would refuse to mount it from sub-device B, even though it would fail to mount A after only a short time window.</li>
  <li>The logic was:
    <ul>
      <li>Goroutine A:
        <ol>
          <li>“booster sends IOCTL to register sub-device A”</li>
          <li>“booster always think sub-device A ready” (definitely not ready)</li>
          <li>“booster mounts sub-device A”</li>
        </ol>
      </li>
      <li>Go routine B:
        <ol>
          <li>“booster sends IOCTL to register sub-device B”</li>
          <li>“booster always think sub-device B ready” (maybe ready)</li>
          <li>“booster mounts sub-device B”</li>
        </ol>
      </li>
    </ul>
  </li>
  <li>When btrfs is a module, when “booster always think sub-device B ready”, it is really ready in most cases; when btrfs is built-in instead of a module, all of the above happen too fast and the failure of mounting from A was not finished yet, so the step “booster sends IOCTL to register sub-device B” ends up being rejected by btrfs kernel module with <code class="language-plaintext highlighter-rouge">EBUSY</code></li>
</ul>

<p>Example logs of failed boots, from which you could tell that booster tries to register <code class="language-plaintext highlighter-rouge">vdc</code> immediately after <code class="language-plaintext highlighter-rouge">vdb</code> while <code class="language-plaintext highlighter-rouge">vdb</code>’s failure was not finished yet and it’s rejected by kernel:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[    0.875882] BTRFS error: device /dev/vdc (254:32) belongs to fsid f1872b6c-c2b3-44fe-930e-bb85fd35d669, and the fs is already mounted, scanned by init (1)
[    0.876896] booster: ioctl(0x90009427): device or resource busy
ioctl(0x90009427): device or resource busy
[    0.877787] BTRFS error (device vdb): devid 2 uuid 1f95bd95-1c49-498f-abf9-a39143b970ce is missing
[    0.878568] BTRFS error (device vdb): failed to read the system array: -2
[    0.880541] BTRFS error (device vdb): open_ctree failed: -2
mount(/dev/vdb): no such file or directory
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>found a new device /dev/vda
blkinfo for /dev/vda: type=mbr UUID=dfd9de13 LABEL=
found a new device /dev/vda1
blkinfo for /dev/vda1: type=fat UUID=8b5649c8 LABEL=NO NAME
found a new device /dev/vdb
blkinfo for /dev/vdb: type=btrfs UUID=f1872b6c-c2b3-44fe-930e-bb85fd35d669 LABEL=
mounting /dev/vdb-&gt;/booster.root, fs=btrfs, flags=0x0, options=
found a new device /dev/vdc
blkinfo for /dev/vd[    0.833072] BTRFS error: device /dev/vdc (254:32) belongs to fsid f1872b6c-c2b3-44fe-930e-bb85fd35d669, and the fs is already mounted, scanned by init (136)
c: type=btrfs UU[    0.834106] booster: ioctl(0x90009427): device or resource busy
ID=f1872b6c-c2b3[    0.834346] BTRFS error (device vdb): devid 2 uuid 1f95bd95-1c49-498f-abf9-a39143b970ce is missing
-44fe-930e-bb85f[    0.835248] BTRFS error (device vdb): failed to read the system array: -2
d35d669 LABEL=
ioctl(0x90009427): device or res[    0.836022] BTRFS error (device vdb): open_ctree failed: -2
ource busy
mount(/dev/vdb): no such file or directory
</code></pre></div></div>

<p>To fix this, we need to check the return status of <code class="language-plaintext highlighter-rouge">BTRFS_IOC_DEVICES_READY</code> and only really considers the FS ready when it returns 0</p>

<p>Now the logic is:</p>
<ul>
  <li>Goroutine A:
    <ol>
      <li>“booster sends IOCTL to register sub-device A”</li>
      <li>“booster knows sub-device A not ready and waits” -&gt; back to step 1</li>
      <li>No bad mounting of sub-device A is performed</li>
    </ol>
  </li>
  <li>Go routine B:
    <ol>
      <li>“booster sends IOCTL to register sub-device B”</li>
      <li>“booster knows sub-device B ready” (definitely ready)</li>
      <li>“booster mounts sub-device B”</li>
    </ol>
  </li>
</ul>

<p>Tested on a VM with three Vdisks, vda as boot, vdb + vdc as btrfs (profile single, raid0, raid1 all tested):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>found a new device /dev/vda
blkinfo for /dev/vda: type=mbr UUID=dfd9de13 LABEL=
found a new device /dev/vdc
found a new device /dev/vda1
blkinfo for /dev/vdc: type=btrfs UUID=f1872b6c-c2b3-44fe-930e-bb85fd35d669 LABEL=
blkinfo for /dev/vda1: type=fat UUID=8b5649c8 LABEL=NO NAME
found a new device /dev/vdb
Waiting for multi-device btrfs at /dev/vdc to become fullly assembled, waited 0 seconds
blkinfo for /dev/vdb: type=btrfs UUID=f1872b6c-c2b3-44fe-930e-bb85fd35d669 LABEL=
mounting /dev/vdb-&gt;/booster.root, fs=btrfs, flags=0x0, options=
Switching to the new userspace now. Да пабачэння!
</code></pre></div></div>

<p>References:</p>
<ul>
  <li>btrfs kernel module returns 0 for ready and 1 for not ready to <code class="language-plaintext highlighter-rouge">BTRFS_IOC_DEVICES_READY</code>: <a href="https://github.com/torvalds/linux/blob/86731a2a651e58953fc949573895f2fa6d456841/fs/btrfs/super.c#L2247">source code</a></li>
  <li>btrfs-progs checks the return value of <code class="language-plaintext highlighter-rouge">BTRFS_IOC_DEVICES_READY</code> with type <code class="language-plaintext highlighter-rouge">int</code> to determine whether the FS is ready: <a href="https://github.com/kdave/btrfs-progs/blob/5d47f58fc37fbc93d630d30183e8a2d3354d58e6/cmds/device.c#L530">source code</a></li>
</ul>]]></content><author><name></name></author><category term="booting" /><summary type="html"><![CDATA[Repost of booster Pull Request #299 created by myself:]]></summary></entry><entry><title type="html">Why OpenWrt DDNS does not start on boot</title><link href="https://7ji.github.io/networking/2025/06/05/why-openwrt-ddns-does-not-start-on-boot.html" rel="alternate" type="text/html" title="Why OpenWrt DDNS does not start on boot" /><published>2025-06-05T07:50:00+00:00</published><updated>2025-06-05T07:50:00+00:00</updated><id>https://7ji.github.io/networking/2025/06/05/why-openwrt-ddns-does-not-start-on-boot</id><content type="html" xml:base="https://7ji.github.io/networking/2025/06/05/why-openwrt-ddns-does-not-start-on-boot.html"><![CDATA[<p><em>Edit on 2025-06-08: <a href="https://github.com/openwrt/luci/pull/7809">My patch</a> to fix this was merged into upstream OpenWrt LuCI master and backported to 24.10, the issue should be gone since 24.10.2.</em></p>

<p>On OpenWrt, DDNS functionality is provided by the opt-in <code class="language-plaintext highlighter-rouge">ddns-scripts</code> package (and optionally <code class="language-plaintext highlighter-rouge">ddns-scripts-[provider]</code> packages), which provides both an <code class="language-plaintext highlighter-rouge">rc-init</code> script <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns</code> and a <code class="language-plaintext highlighter-rouge">hotplug.d</code> hook <code class="language-plaintext highlighter-rouge">/etc/hotplug.d/iface/95-ddns</code> to start it automatically:</p>
<ul>
  <li>
    <p>The <code class="language-plaintext highlighter-rouge">rc-init</code> script, instead of starting a “daemon” and maintaining it like other scripts that uses <code class="language-plaintext highlighter-rouge">procd</code>, mainly just calls <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh</code>, which without explicit interface names after <code class="language-plaintext highlighter-rouge">start/stop/reload</code> just double forks the workers and quits. Note it has an empty <code class="language-plaintext highlighter-rouge">boot()</code> function which shadows <code class="language-plaintext highlighter-rouge">start()</code> on boot, i.e. <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -- start</code> would not be run on boot.</p>

    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/init.d/ddns
</code></pre></div>    </div>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c">#!/bin/sh /etc/rc.common</span>
  <span class="nv">START</span><span class="o">=</span>95
  <span class="nv">STOP</span><span class="o">=</span>10

  boot<span class="o">()</span> <span class="o">{</span>
          <span class="k">return </span>0
  <span class="o">}</span>

  reload<span class="o">()</span> <span class="o">{</span>
          /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">--</span> reload
          <span class="k">return </span>0
  <span class="o">}</span>

  restart<span class="o">()</span> <span class="o">{</span>
          /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">--</span> stop
          <span class="nb">sleep </span>1 <span class="c"># give time to shutdown</span>
          /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">--</span> start
  <span class="o">}</span>

  start<span class="o">()</span> <span class="o">{</span>
          /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">--</span> start
  <span class="o">}</span>

  stop<span class="o">()</span> <span class="o">{</span>
          /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">--</span> stop
          <span class="k">return </span>0
  <span class="o">}</span>

</code></pre></div>    </div>
  </li>
  <li>
    <p>The <code class="language-plaintext highlighter-rouge">hotplug.d</code> hook starts instances for “interfaces” when they’re brought up by <code class="language-plaintext highlighter-rouge">netifd</code> and triggers <code class="language-plaintext highlighter-rouge">hotplug</code> event (e.g. when you <code class="language-plaintext highlighter-rouge">ifup</code> manually, or <code class="language-plaintext highlighter-rouge">reconnect</code> an interface from LuCI, or they start up automatically on boot after <code class="language-plaintext highlighter-rouge">netifd</code> is up and running):</p>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/hotplug.d/iface/95-ddns
</code></pre></div>    </div>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c">#!/bin/sh</span>

  <span class="c"># there are other ACTIONs like ifupdate we don't need</span>
  <span class="k">case</span> <span class="s2">"</span><span class="nv">$ACTION</span><span class="s2">"</span> <span class="k">in
          </span>ifup<span class="p">)</span>                                   <span class="c"># OpenWrt is giving a network not phys. Interface</span>
                  /etc/init.d/ddns enabled <span class="o">&amp;&amp;</span> /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$INTERFACE</span><span class="s2">"</span> <span class="nt">--</span> start
                  <span class="p">;;</span>
          ifdown<span class="p">)</span>
                  /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$INTERFACE</span><span class="s2">"</span> <span class="nt">--</span> stop
                  <span class="p">;;</span>
  <span class="k">esac</span>
</code></pre></div>    </div>
  </li>
</ul>

<p>Both the <code class="language-plaintext highlighter-rouge">rc-init</code> script and the <code class="language-plaintext highlighter-rouge">hotplug.d</code> maintain nothing: they just spawn workers for interfaces, either fork and run <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -n "$INTERFACE" -- start</code> by itself, or from a convenient shortcut provided by <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -- start</code> which iterates uci config <code class="language-plaintext highlighter-rouge">ddns</code> internally to do the work.</p>

<p>So on boot the intended logic that <code class="language-plaintext highlighter-rouge">dynamic_dns_updater</code> shall be spawned on interfaces is as follows:</p>
<ul>
  <li>The early init stage</li>
  <li>The procd <code class="language-plaintext highlighter-rouge">exec</code>-ed by early init and becomes new PID 1</li>
  <li>The <code class="language-plaintext highlighter-rouge">ubusd</code> becomes ready</li>
  <li>The <code class="language-plaintext highlighter-rouge">/etc/init.d/network</code> starts, and spawns <code class="language-plaintext highlighter-rouge">netifd</code> in <code class="language-plaintext highlighter-rouge">procd</code></li>
  <li>The <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns</code> starts, and due to empty <code class="language-plaintext highlighter-rouge">boot()</code> it does nothing</li>
  <li>The wan interface becomes ready in <code class="language-plaintext highlighter-rouge">netifd</code></li>
  <li>The <code class="language-plaintext highlighter-rouge">/etc/hotplug.d/iface/95-ddns</code> hook triggers on interface(s) that you have configured <code class="language-plaintext highlighter-rouge">ddns</code> on, and the corresponding worker(s) would be spawned.</li>
</ul>

<p>Note that the <code class="language-plaintext highlighter-rouge">hotplug.d</code> hook uses the internal name used by <code class="language-plaintext highlighter-rouge">netifd</code>. That is, an “physical” “interface” might e.g. be called as <code class="language-plaintext highlighter-rouge">br-lan</code> in the scope of Linux, but would be called <code class="language-plaintext highlighter-rouge">lan</code> in the scope of <code class="language-plaintext highlighter-rouge">netifd</code>, <code class="language-plaintext highlighter-rouge">uci</code>, <code class="language-plaintext highlighter-rouge">LuCI</code>, etc and of course <code class="language-plaintext highlighter-rouge">hotplug.d</code>.</p>

<p>Now let’s discuss about an “issue”: many with a <code class="language-plaintext highlighter-rouge">PPPoE wan</code> might find a strange phenomenon: even though they have “enabled” the <code class="language-plaintext highlighter-rouge">ddns</code> service and configured it on <code class="language-plaintext highlighter-rouge">pppoe-wan</code> “interface”, the ddns worker would not correctly start on boot on their <code class="language-plaintext highlighter-rouge">PPPoE wan</code> interface. The reason this issue happens is due to the combination of following factors:</p>
<ul>
  <li>In OpenWrt, software-based “interface”s are named in the style of <code class="language-plaintext highlighter-rouge">[protocol]-[network]</code>, e.g. for PPPoE-based “wan” interface/network, the actual Linux interface name that’s created would be <code class="language-plaintext highlighter-rouge">pppoe-wan</code>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>config interface 'wan'
        option device 'eth5'
        option proto 'pppoe'
        option username 'xxxxxxxx'
        option password 'yyyyyy'
        option keepalive '10 60'
        option ipv6 'auto
</code></pre></div>    </div>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ip l | grep wan
19: pppoe-wan: &lt;POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1492 qdisc fq_codel state UNKNOWN mode DEFAULT group default qlen 3
</code></pre></div>    </div>
  </li>
  <li><a href="https://github.com/openwrt/luci/blob/55c93e60b4e598e81eeb1774d5d83ac32b245016/applications/luci-app-ddns/htdocs/luci-static/resources/view/ddns/overview.js#L899">In luci-app-ddns</a>, the “interface” attribute is derived from the current source network/interface, e.g. when you configure “network” “wan”, this would be <code class="language-plaintext highlighter-rouge">wan</code>; when you configure “interface” “pppoe-wan”, this would be <code class="language-plaintext highlighter-rouge">pppoe-wan</code>:
    <div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">o</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">taboption</span><span class="p">(</span><span class="s1">'advanced'</span><span class="p">,</span> <span class="n">form</span><span class="p">.</span><span class="n">DummyValue</span><span class="p">,</span> <span class="s1">'_interface'</span><span class="p">,</span>
                      <span class="n">_</span><span class="p">(</span><span class="s2">"Event Network"</span><span class="p">),</span>
                      <span class="n">_</span><span class="p">(</span><span class="s2">"Network on which the ddns-updater scripts will be started"</span><span class="p">));</span>
<span class="n">o</span><span class="p">.</span><span class="n">depends</span><span class="p">(</span><span class="s2">"ip_source"</span><span class="p">,</span> <span class="s2">"interface"</span><span class="p">);</span>
<span class="n">o</span><span class="p">.</span><span class="n">depends</span><span class="p">(</span><span class="s2">"ip_source"</span><span class="p">,</span> <span class="s2">"network"</span><span class="p">);</span>
<span class="n">o</span><span class="p">.</span><span class="n">forcewrite</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">o</span><span class="p">.</span><span class="n">modalonly</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="n">o</span><span class="p">.</span><span class="n">cfgvalue</span> <span class="o">=</span> <span class="k">function</span><span class="p">(</span><span class="n">section_id</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="n">uci</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s1">'ddns'</span><span class="p">,</span> <span class="n">section_id</span><span class="p">,</span> <span class="s1">'interface'</span><span class="p">)</span> <span class="err">||</span> <span class="n">_</span><span class="p">(</span><span class="s1">'This will be autoset to the selected interface'</span><span class="p">);</span>
<span class="p">};</span>
<span class="n">o</span><span class="p">.</span><span class="n">write</span> <span class="o">=</span> <span class="k">function</span><span class="p">(</span><span class="n">section_id</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">var</span> <span class="n">opt</span> <span class="o">=</span> <span class="n">this</span><span class="p">.</span><span class="n">section</span><span class="p">.</span><span class="n">formvalue</span><span class="p">(</span><span class="n">section_id</span><span class="p">,</span> <span class="s1">'ip_source'</span><span class="p">);</span>
  <span class="n">var</span> <span class="n">val</span> <span class="o">=</span> <span class="n">this</span><span class="p">.</span><span class="n">section</span><span class="p">.</span><span class="n">formvalue</span><span class="p">(</span><span class="n">section_id</span><span class="p">,</span> <span class="s1">'ip_'</span><span class="o">+</span><span class="n">opt</span><span class="p">);</span>
  <span class="k">return</span> <span class="n">uci</span><span class="p">.</span><span class="n">set</span><span class="p">(</span><span class="s1">'ddns'</span><span class="p">,</span> <span class="n">section_id</span><span class="p">,</span> <span class="s1">'interface'</span><span class="p">,</span> <span class="n">val</span><span class="p">);</span>
<span class="p">};</span>
</code></pre></div>    </div>
  </li>
  <li><a href="https://github.com/openwrt/packages/blob/08b4fcd5e6b2ec5853c7eedd548bff0d3f541fbe/net/ddns-scripts/files/usr/lib/ddns/dynamic_dns_updater.sh#L135">In dynamic_dns_updater.sh</a> i.e. the actual updater worker, the uci attribute <code class="language-plaintext highlighter-rouge">interface</code> needs to be the OpenWrt/netifd internal name that’s put on the “network” / Openwrt “interface”, not the Linux “interface”:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># interface 	network interface used by hotplug.d i.e. 'wan' or 'wan6'
</code></pre></div>    </div>
  </li>
  <li><a href="https://github.com/openwrt/packages/blob/08b4fcd5e6b2ec5853c7eedd548bff0d3f541fbe/net/ddns-scripts/files/usr/lib/ddns/dynamic_dns_functions.sh#L179">In dynamic_dns_functions.sh</a>, the <code class="language-plaintext highlighter-rouge">start_daemon_for_all_ddns_sections</code> takes the “network” name as argument and tries to get one <code class="language-plaintext highlighter-rouge">ddns</code> section with <code class="language-plaintext highlighter-rouge">interface</code> equalling it (note <code class="language-plaintext highlighter-rouge">wan</code> is the fallback name):
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># starts updater script for all given sections or only for the one given</span>
<span class="c"># $1 = interface (Optional: when given only scripts are started</span>
<span class="c"># configured for that interface)</span>
<span class="c"># used by /etc/hotplug.d/iface/95-ddns on IFUP</span>
<span class="c"># and by /etc/init.d/ddns start</span>
start_daemon_for_all_ddns_sections<span class="o">()</span>
<span class="o">{</span>
    <span class="nb">local </span>event_if sections section_id configured_if
    <span class="nv">event_if</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>

    load_all_service_sections sections
    <span class="k">for </span>section_id <span class="k">in</span> <span class="nv">$sections</span><span class="p">;</span> <span class="k">do
      </span>config_get configured_if <span class="s2">"</span><span class="nv">$section_id</span><span class="s2">"</span> interface <span class="s2">"wan"</span>
      <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$event_if</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$configured_if</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"</span><span class="nv">$event_if</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue</span>
      /usr/lib/ddns/dynamic_dns_updater.sh <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$VERBOSE</span><span class="s2">"</span> <span class="nt">-S</span> <span class="s2">"</span><span class="nv">$section_id</span><span class="s2">"</span> <span class="nt">--</span> start &amp;
    <span class="k">done</span>
<span class="o">}</span>
</code></pre></div>    </div>
  </li>
  <li>When retrieving network information from <code class="language-plaintext highlighter-rouge">netifd</code>, the “interface” must be the Openwrt “interface” / network, not the Linux “interface”. There’s no internal fallback logic to get the info from a Linux “interface”.
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> ubus call network.interface status <span class="s1">'{"interface":"wan"}'</span> | jsonfilter <span class="nt">-e</span> <span class="s1">'@["ipv4-address"][0].address'</span>
xxx.xxx.xxx.xxx
<span class="o">&gt;</span> ubus call network.interface status <span class="s1">'{"interface":"pppoe-wan"}'</span> | jsonfilter <span class="nt">-e</span> <span class="s1">'@["ipv4-address"][0].address'</span>
Command failed: Not found
Failed to parse json data: unexpected end of data
</code></pre></div>    </div>
  </li>
  <li>Likewise, the hotplug event only triggers on <code class="language-plaintext highlighter-rouge">wan</code>, not on <code class="language-plaintext highlighter-rouge">pppoe-wan</code></li>
  <li>So, the <code class="language-plaintext highlighter-rouge">hotplug</code> event actually triggers and it runs <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -n wan -- start</code> to start the worker for interface <code class="language-plaintext highlighter-rouge">wan</code>, but as it could not find any config section in <code class="language-plaintext highlighter-rouge">/etc/config/ddns</code> with <code class="language-plaintext highlighter-rouge">interface=wan</code> (which in reality is <code class="language-plaintext highlighter-rouge">interface=pppoe-wan</code>), it just quits and nevers spawns the actual <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -S SECTION -- start</code> worker.</li>
</ul>

<p>Note that while <code class="language-plaintext highlighter-rouge">hotplug.d</code> logic fails, you can still run <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns start</code> to effectively run <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -- start</code>, which just iterates the whole <code class="language-plaintext highlighter-rouge">/etc/config/ddns</code> config and would start all workers for all sections (as <code class="language-plaintext highlighter-rouge">-n NETWORK</code> is skipped and <code class="language-plaintext highlighter-rouge">-S SECTION</code> is run directly).</p>

<p>This of course does not only affect <code class="language-plaintext highlighter-rouge">PPPoE</code> <code class="language-plaintext highlighter-rouge">wan</code>, but in general affects any interface that’s named differently from the corresponding network name.</p>

<p>There are two correct way to fix the issue, one is simply LuCI-only, and another one needs some uci (or manual config editting) but does not touch logic codes:</p>
<ul>
  <li>The simple way is, without touching any of the above code, to configure your DDNS instance with source as “network” “wan”, instead of “interface” “pppoe-wan”, so you have <code class="language-plaintext highlighter-rouge">interface=wan</code> in your <code class="language-plaintext highlighter-rouge">/etc/config/ddns</code> and this way the <code class="language-plaintext highlighter-rouge">hotplug</code> event would correctly starts on “network” “wan”</li>
  <li>Another way is, to modify the <code class="language-plaintext highlighter-rouge">interface</code> value (you can also edit <code class="language-plaintext highlighter-rouge">/etc/config/ddns</code> manually)
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uci set ddns.cfv4.interface=wan
uci commit ddns
</code></pre></div>    </div>
  </li>
</ul>

<p>Now with this knowledge you shall know that why the following “band-aid” “hacks” seem to “fix” the “problem” but they are very unreliable.</p>
<ul>
  <li>By removing <code class="language-plaintext highlighter-rouge">boot()</code> function in <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns</code>, you can force <code class="language-plaintext highlighter-rouge">/usr/lib/ddns/dynamic_dns_updater.sh -- start</code> to run on boot, which would spawn workers for each configured section. The workers are there, but if another ifdown &amp; ifup occurs then they could break. As the intended way with hotplug.d is that the worker shall be brought up after ifup and brough down before ifdown.</li>
  <li>By putting <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns restart</code> in your <code class="language-plaintext highlighter-rouge">/etc/rc.local</code>, you’re basically doing the same thing as removing <code class="language-plaintext highlighter-rouge">boot()</code></li>
  <li>By putting both <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns restart</code> in your <code class="language-plaintext highlighter-rouge">/etc/rc.local</code>, and a <code class="language-plaintext highlighter-rouge">sleep</code> before it, you have the addtional hope that <code class="language-plaintext highlighter-rouge">pppoe-wan</code> definitely becomes online after that timeout, however it’s not guaranteed.</li>
  <li>By removing <code class="language-plaintext highlighter-rouge">boot()</code> function in <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns</code>, putting <code class="language-plaintext highlighter-rouge">sleep</code> and <code class="language-plaintext highlighter-rouge">/etc/init.d/ddns restart</code> in your <code class="language-plaintext highlighter-rouge">/etc/rc.local</code>. You’re combining “band-aid”s which makes your device more and more non-reproducible.</li>
  <li>Things can still fail after the above “band-aids” if your <code class="language-plaintext highlighter-rouge">pppoe-wan</code> connection is not there and you have configured <code class="language-plaintext highlighter-rouge">retry_max_count</code> for DDNS sections. If you use <code class="language-plaintext highlighter-rouge">hotplug.d</code> then the worker is guaranteed to be started on <code class="language-plaintext highlighter-rouge">pppoe-wan</code> creation and stopped on <code class="language-plaintext highlighter-rouge">pppoe-wan</code> destruction.</li>
</ul>]]></content><author><name></name></author><category term="networking" /><summary type="html"><![CDATA[Edit on 2025-06-08: My patch to fix this was merged into upstream OpenWrt LuCI master and backported to 24.10, the issue should be gone since 24.10.2.]]></summary></entry><entry><title type="html">Gotchas when booting from virtiofs root</title><link href="https://7ji.github.io/booting/2025/05/23/gotchas-when-booting-from-virtiofs-root.html" rel="alternate" type="text/html" title="Gotchas when booting from virtiofs root" /><published>2025-05-23T10:00:00+00:00</published><updated>2025-05-23T10:00:00+00:00</updated><id>https://7ji.github.io/booting/2025/05/23/gotchas-when-booting-from-virtiofs-root</id><content type="html" xml:base="https://7ji.github.io/booting/2025/05/23/gotchas-when-booting-from-virtiofs-root.html"><![CDATA[<p>virtiofs is a nice host-passthrough “fs” with which a virtual machine can use the host fs almost directly, saving the overhead of “guest fs -&gt; guest disk -&gt; host file -&gt; host fs” hassle, a virtiofs root is especially useful when access to files in guest root fs from host directly is needed, e.g. when doing frequent debugging, or vice versa.</p>

<p>It might seem enough to just set the kernel cmdline to:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">root</span><span class="o">=</span>root <span class="nv">rootfstype</span><span class="o">=</span>virtiofs
</code></pre></div></div>

<p>However to make a virtual machine actually boot from such root you need to go through a few gotchas:</p>

<h2 id="common">Common</h2>
<ul>
  <li>In libvirt, you need to allocate a <code class="language-plaintext highlighter-rouge">dir</code> type storage pool and then <code class="language-plaintext highlighter-rouge">mkdir</code> a root-owned subfolder inside the pool, the pool could be reused while the subfolder needs to be VM-specific, the corresponding storage config file looks like this:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">cat</span> /etc/libvirt/storage/filesystems.xml
</code></pre></div>    </div>
    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;pool</span> <span class="na">type=</span><span class="s">'dir'</span><span class="nt">&gt;</span>
  <span class="nt">&lt;name&gt;</span>filesystems<span class="nt">&lt;/name&gt;</span>
  <span class="nt">&lt;uuid&gt;</span>b36d710f-7a61-49f7-935b-061b12f24c8a<span class="nt">&lt;/uuid&gt;</span>
  <span class="nt">&lt;capacity</span> <span class="na">unit=</span><span class="s">'bytes'</span><span class="nt">&gt;</span>0<span class="nt">&lt;/capacity&gt;</span>
  <span class="nt">&lt;allocation</span> <span class="na">unit=</span><span class="s">'bytes'</span><span class="nt">&gt;</span>0<span class="nt">&lt;/allocation&gt;</span>
  <span class="nt">&lt;available</span> <span class="na">unit=</span><span class="s">'bytes'</span><span class="nt">&gt;</span>0<span class="nt">&lt;/available&gt;</span>
  <span class="nt">&lt;source&gt;</span>
  <span class="nt">&lt;/source&gt;</span>
  <span class="nt">&lt;target&gt;</span>
      <span class="nt">&lt;path&gt;</span>/var/lib/libvirt/filesystems<span class="nt">&lt;/path&gt;</span>
  <span class="nt">&lt;/target&gt;</span>
  <span class="nt">&lt;/pool&gt;</span>
</code></pre></div>    </div>
    <p>The subfolder:</p>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">ls</span> <span class="nt">-ldh</span> /var/lib/libvirt/filesystems/root.debian12
  drwxr-xr-x 18 root root 4.0K May 23 17:55 /var/lib/libvirt/filesystems/root.debian12/
</code></pre></div>    </div>
  </li>
  <li>The guest needs to have a “filesystem” “device” that points to the subfolder, i.e. the guest config shall have the following snippet:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'/&lt;filesystem/,/&lt;\/filesystem/p'</span> /etc/libvirt/qemu/debian12.xml
</code></pre></div>    </div>
    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;filesystem</span> <span class="na">type=</span><span class="s">'mount'</span> <span class="na">accessmode=</span><span class="s">'passthrough'</span><span class="nt">&gt;</span>
    <span class="nt">&lt;driver</span> <span class="na">type=</span><span class="s">'virtiofs'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;source</span> <span class="na">dir=</span><span class="s">'/var/lib/libvirt/filesystems/root.debian12'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;target</span> <span class="na">dir=</span><span class="s">'root'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x08'</span> <span class="na">slot=</span><span class="s">'0x00'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;/filesystem&gt;</span>
</code></pre></div>    </div>
  </li>
  <li>The guest fstab needs only the following line then:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="s">root / virtiofs defaults 0 </span><span class="m">0</span>
</code></pre></div>    </div>
  </li>
  <li>If you need to use overlayfs with upperdir pointing to path inside the virtual root, the <code class="language-plaintext highlighter-rouge">virtiofsd</code> needs addtional arguments to allow xattr and keep <code class="language-plaintext highlighter-rouge">cap_sysadmin</code>:
    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;filesystem</span> <span class="na">type=</span><span class="s">'mount'</span> <span class="na">accessmode=</span><span class="s">'passthrough'</span><span class="nt">&gt;</span>
    <span class="nt">&lt;driver</span> <span class="na">type=</span><span class="s">'virtiofs'</span> <span class="na">queue=</span><span class="s">'1024'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;binary</span> <span class="na">path=</span><span class="s">'/usr/lib/virtiofsd+sys_admin'</span> <span class="na">xattr=</span><span class="s">'on'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;source</span> <span class="na">dir=</span><span class="s">'/var/lib/libvirt/filesystems/root.arb-x64-builder'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;target</span> <span class="na">dir=</span><span class="s">'root'</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;address</span> <span class="na">type=</span><span class="s">'pci'</span> <span class="na">domain=</span><span class="s">'0x0000'</span> <span class="na">bus=</span><span class="s">'0x01'</span> <span class="na">slot=</span><span class="s">'0x00'</span> <span class="na">function=</span><span class="s">'0x0'</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;/filesystem&gt;</span>
</code></pre></div>    </div>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">cat</span> /usr/lib/virtiofsd+sys_admin
</code></pre></div>    </div>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c">#!/bin/sh</span>
  <span class="nb">exec</span> /usr/lib/virtiofsd <span class="nt">-o</span> <span class="nv">modcaps</span><span class="o">=</span>+sys_admin <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
</code></pre></div>    </div>
  </li>
  <li>If you want to share something between VMs it’s not recommended to re-use the root subfoldder (which is as bad as mounting a virtual disk that is being used), just create another shared subfolder.</li>
</ul>

<h2 id="debian-guest">Debian guest</h2>

<ul>
  <li>You must either install a system to a virtual disk first then extract the files, or debootstrap manually</li>
  <li>If the system rootfs folder was extracted from a locally installed disk image, then <code class="language-plaintext highlighter-rouge">/etc/initramfs-tools/conf.d/resume</code> must be purged before a rerun of <code class="language-plaintext highlighter-rouge">update-initramfs -u</code>, otherwise the initrd would try to resume from the missing block device and break</li>
  <li>You must include <code class="language-plaintext highlighter-rouge">virtiofs</code> in <code class="language-plaintext highlighter-rouge">/etc/initramfs-tools/modules</code>, otherwise the <code class="language-plaintext highlighter-rouge">virtiofs</code> module would not be included (unfortunately initramfs-tools’ hook only picks fs for a root fs mounted from a block device or nfs). Addtionally you could set <code class="language-plaintext highlighter-rouge">FSTYPE=virtiofs</code> in <code class="language-plaintext highlighter-rouge">/etc/initramfs-tools/initramfs.conf</code> to omit fsck hook (not recommended, as this 1. does not include <code class="language-plaintext highlighter-rouge">virtiofs</code> while it looks it should and 2. would render the system unusable if you forget to revert it in the future when you want to disgard the virtiofs root).</li>
  <li>A run of <code class="language-plaintext highlighter-rouge">update-initramfs -u</code> is certainly needed after the above gotchas sorted out.</li>
  <li>
    <p>The guest kernel needs to be booted directly by hypervisor, without a bootloader, and it’s recommended to use the <code class="language-plaintext highlighter-rouge">vmlinuz</code> and <code class="language-plaintext highlighter-rouge">initrd.img</code> symlinks instead of the real files:</p>

    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'/&lt;os/,/&lt;\/os/p'</span> /etc/libvirt/qemu/debian12.xml
</code></pre></div>    </div>
    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;os&gt;</span>
      <span class="nt">&lt;type</span> <span class="na">arch=</span><span class="s">'x86_64'</span> <span class="na">machine=</span><span class="s">'pc-q35-10.0'</span><span class="nt">&gt;</span>hvm<span class="nt">&lt;/type&gt;</span>
      <span class="nt">&lt;kernel&gt;</span>/var/lib/libvirt/filesystems/root.debian12/vmlinuz<span class="nt">&lt;/kernel&gt;</span>
      <span class="nt">&lt;initrd&gt;</span>/var/lib/libvirt/filesystems/root.debian12/initrd.img<span class="nt">&lt;/initrd&gt;</span>
      <span class="nt">&lt;cmdline&gt;</span>root=root rootfstype=virtiofs<span class="nt">&lt;/cmdline&gt;</span>
      <span class="nt">&lt;boot</span> <span class="na">dev=</span><span class="s">'hd'</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;bootmenu</span> <span class="na">enable=</span><span class="s">'no'</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;/os&gt;</span>
</code></pre></div>    </div>
  </li>
  <li>If you’ve prepared the root from a virtual disk bootable image, grub stuffs needs to be purged later:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt purge --autoremove grub2 grub-common
rm -rf /boot/grub
</code></pre></div>    </div>
  </li>
</ul>

<h2 id="arch-linux-guest">Arch Linux guest</h2>
<ul>
  <li>Only <code class="language-plaintext highlighter-rouge">mkinitcpio</code> and <code class="language-plaintext highlighter-rouge">dracut</code> initrd makers support booting from <code class="language-plaintext highlighter-rouge">virtiofs</code> natively, <code class="language-plaintext highlighter-rouge">booster</code> needs <a href="https://github.com/anatol/booster/pull/298">my patchset</a>, but <code class="language-plaintext highlighter-rouge">dracut</code> is not recommended as its initramfs paths are undetermined containing kernel version (see below).</li>
  <li>The target rootfs can be prepared by either <code class="language-plaintext highlighter-rouge">pacstrapping</code> in host using <code class="language-plaintext highlighter-rouge">arch-install-scripts</code> or <code class="language-plaintext highlighter-rouge">pacstrapping</code> in target using archiso, it’s not recommended to extract the rootfs from a installed system that runs on either virtual disk or physical machine</li>
  <li>
    <p>The guest kernel needs to be booted directly by hypervisor, without a bootloader, the kernel path is determined and the initrd path also unless you’re using <code class="language-plaintext highlighter-rouge">dracut</code> (so better use <code class="language-plaintext highlighter-rouge">booster</code> or <code class="language-plaintext highlighter-rouge">mkinitcpio</code>)</p>

    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">&gt;</span> <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'/&lt;os/,/&lt;\/os/p'</span> /etc/libvirt/qemu/archlinux.xml
</code></pre></div>    </div>
    <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;os&gt;</span>
      <span class="nt">&lt;type</span> <span class="na">arch=</span><span class="s">'x86_64'</span> <span class="na">machine=</span><span class="s">'pc-q35-10.0'</span><span class="nt">&gt;</span>hvm<span class="nt">&lt;/type&gt;</span>
      <span class="nt">&lt;kernel&gt;</span>/var/lib/libvirt/filesystems/root.archlinux/boot/vmlinuz-linux<span class="nt">&lt;/kernel&gt;</span>
      <span class="nt">&lt;initrd&gt;</span>/var/lib/libvirt/filesystems/root.archlinux/boot/booster-linux.img<span class="nt">&lt;/initrd&gt;</span>
      <span class="nt">&lt;cmdline&gt;</span>root=root rootfstype=virtiofs<span class="nt">&lt;/cmdline&gt;</span>
      <span class="nt">&lt;boot</span> <span class="na">dev=</span><span class="s">'hd'</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;bootmenu</span> <span class="na">enable=</span><span class="s">'no'</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;/os&gt;</span>
</code></pre></div>    </div>
  </li>
</ul>]]></content><author><name></name></author><category term="booting" /><summary type="html"><![CDATA[virtiofs is a nice host-passthrough “fs” with which a virtual machine can use the host fs almost directly, saving the overhead of “guest fs -&gt; guest disk -&gt; host file -&gt; host fs” hassle, a virtiofs root is especially useful when access to files in guest root fs from host directly is needed, e.g. when doing frequent debugging, or vice versa.]]></summary></entry><entry><title type="html">RTL8373-based cheap 2.5 Gbps switches are troublemakers</title><link href="https://7ji.github.io/networking/2025/04/16/rtl8373-based-cheap-switches-are-troublemakers.html" rel="alternate" type="text/html" title="RTL8373-based cheap 2.5 Gbps switches are troublemakers" /><published>2025-04-16T02:00:00+00:00</published><updated>2025-04-16T02:00:00+00:00</updated><id>https://7ji.github.io/networking/2025/04/16/rtl8373-based-cheap-switches-are-troublemakers</id><content type="html" xml:base="https://7ji.github.io/networking/2025/04/16/rtl8373-based-cheap-switches-are-troublemakers.html"><![CDATA[<p>RTL8373 is great: it’s the cheapest L2 network switching IC that provides 8x 2.5 Gbps ethernet ports (4 with its built-in PHY, 4 with external PHYs from RTL8224), 1 10 Gbps SFP port, and on top of them fancy L2 management features like VLAN, Link aggregation, etc.</p>

<p>From around March 2024 I’ve bought 5 of these switches, three of them are from Sirivision, and the remaining two from Hellotek. The price kept dropping as I bought more of them, going from around 290 CNY in March 2024 to around 210 CNY in March 2025. It’s almost a steal!</p>

<p>These are all located at different places: two of which were installed at my new house at my hometown to provide in-home 2.5 Gbps networking, two of which were installed at my rent house at the city I work to also provide in-home 2.5 Gbps networking in addition to L3 10 Gbps core switching, and the last one installed in my office for 2.5 Gbps switching among my wired devices.</p>

<p>As they came from some almost unknown branch I didn’t expect much from their stock firmware, but the Sirivision switches tend out to be very feature-full: features go from VLAN tagging, LAG, IGMP snoofing to DHCP spoofing protection, almost like they’ve cut nothing from what Realtek left in their reference BSP and exposed everything, their web UI looks very simple and I appreciate that; on the other hand the Hellotek ones provide only limited features, with only base VLAN tagging, and LAG with limitation (one group must be on 4 native ports and the other group must be on 4 “external” ports), they got no IGMP snoofing, no DHCP spoofing protection, while they have nice web UI it just adds the shame that they choose to limit the features exposed.</p>

<p>They both seem nice and sound, considering their price, providing cheap high speed switching with supposedly little to no hassle. However, in reality they’re really troublemakers, and I’ll summarize the problems below:</p>

<ol>
  <li>
    <p>Configuration is applied only after booting (only affecting Sirivision)</p>

    <p>The VLAN, LAG, etc settings are not applied directly after booting. There’s a short time window (1-2 second) during which the switch acts as a dumb switch.</p>

    <p>If you use the switch just as a simple dumb switch without VLAN, LAG, etc then you are safe. However if you have untagged VLAN your untagged traffic would go rogue and escape the VLAN filtering; if you have LAG the LAG ports would forward traffic to each other resulting in loops:</p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Apr 05 23:57:18 wn1 kernel: igc 0000:02:00.0 enp2s0: NIC Link is Down
 Apr 05 23:57:18 wn1 kernel: igc 0000:03:00.0 enp3s0: NIC Link is Down
 Apr 05 23:57:19 wn1 kernel: bond0: (slave enp2s0): link status definitely down, disabling slave
 Apr 05 23:57:19 wn1 kernel: bond0: (slave enp3s0): link status definitely down, disabling slave
 Apr 05 23:57:19 wn1 kernel: bond0: now running without any active interface!
 Apr 05 23:57:19 wn1 kernel: bridge0: port 1(bond0) entered disabled state
 Apr 05 23:57:40 wn1 kernel: igc 0000:03:00.0 enp3s0: NIC Link is Up 2500 Mbps Full Duplex, Flow Control: RX/TX
 Apr 05 23:57:40 wn1 kernel: igc 0000:02:00.0 enp2s0: NIC Link is Up 2500 Mbps Full Duplex, Flow Control: RX/TX
 Apr 05 23:57:40 wn1 kernel: bond0: (slave enp2s0): link status definitely up, 2500 Mbps full duplex
 Apr 05 23:57:40 wn1 kernel: bond0: (slave enp3s0): link status definitely up, 2500 Mbps full duplex
 Apr 05 23:57:40 wn1 kernel: bond0: active interface up!
 Apr 05 23:57:40 wn1 kernel: bridge0: port 1(bond0) entered blocking state
 Apr 05 23:57:40 wn1 kernel: bridge0: port 1(bond0) entered forwarding state
 Apr 05 23:57:40 wn1 kernel: bridge0: received packet on bond0 with own address as source address (addr:f6:fe:da:c5:1d:90, vlan:0)
 Apr 05 23:57:40 wn1 kernel: bridge0: received packet on bond0 with own address as source address (addr:f6:fe:da:c5:1d:90, vlan:0)
 Apr 05 23:57:40 wn1 kernel: bridge0: received packet on bond0 with own address as source address (addr:f6:fe:da:c5:1d:90, vlan:0)
 Apr 05 23:57:41 wn1 kernel: igc 0000:02:00.0 enp2s0: NIC Link is Down
 Apr 05 23:57:41 wn1 kernel: igc 0000:03:00.0 enp3s0: NIC Link is Down
 Apr 05 23:57:41 wn1 kernel: bond0: (slave enp2s0): link status definitely down, disabling slave
 Apr 05 23:57:41 wn1 kernel: bond0: (slave enp3s0): link status definitely down, disabling slave
 Apr 05 23:57:41 wn1 kernel: bond0: now running without any active interface!
 Apr 05 23:57:41 wn1 kernel: bridge0: port 1(bond0) entered disabled state
 Apr 05 23:57:44 wn1 kernel: igc 0000:02:00.0 enp2s0: NIC Link is Up 2500 Mbps Full Duplex, Flow Control: RX/TX
 Apr 05 23:57:44 wn1 kernel: igc 0000:03:00.0 enp3s0: NIC Link is Up 2500 Mbps Full Duplex, Flow Control: RX/TX
 Apr 05 23:57:45 wn1 kernel: bond0: (slave enp2s0): link status definitely up, 2500 Mbps full duplex
 Apr 05 23:57:45 wn1 kernel: bond0: (slave enp3s0): link status definitely up, 2500 Mbps full duplex
 Apr 05 23:57:45 wn1 kernel: bond0: active interface up!
 Apr 05 23:57:45 wn1 kernel: bridge0: port 1(bond0) entered blocking state
 Apr 05 23:57:45 wn1 kernel: bridge0: port 1(bond0) entered forwarding state
</code></pre></div>    </div>

    <p>These are bad things and you definitely would not like them in your network.</p>

    <p>Luckily things are not too bad as you would not reboot the switch very frequently. And the issue seems only affecting Sirivision and not Hellotek.</p>
  </li>
  <li>
    <p>VLAN 1 cannot be safely deleted</p>

    <p>On Hellotek VLAN 1 cannot be deleted at all: the button is grey; on Sirivion you seem to be able to delete it, but deleting it results in a freezing interface with glitched characters as an indication of memory leak.</p>

    <p>Both of these seem to be using VLAN 1 for their internal logic, and while it seems still available for modification you’d better left them in place, just removing ports from it if you want some modification.</p>
  </li>
  <li>
    <p>Management VLAN cannot be set</p>

    <p>The Sirivision ones do not provide the option to modify the management VLAN, while the Helloteks ones do provide the option but it never take effect. The management VLAN can only be 1, but it’s not 1 actually, there’s just no “management VLAN” in fact, which comes to the next issue.</p>

    <p>And your know what? It seems Hellotek “has” management VLAN but that only works for external traffic! I.e. when through a stacked switch, you now can only access another switch from untagged traffic, how secure and convenient!</p>
  </li>
  <li>
    <p>Management packets are captured from all VLANs</p>

    <p>The switch captures any traffic targetting its IP instead of only from a specific management VLAN. To make things worse, while the Sirivision one captures these traffic from even external traffic forwarded from other switches (i.e. a stacked config), the Hellotek ones only capture such traffic from ports directly connected to it physically.</p>

    <p>This is a bad thing. E.g. let’s assume you left the switch to keep its default 192.168.1.199 mangement IP, and you have a VLAN 123 running the 192.168.1.0/24 network, your access to 192.168.1.199 in VLAN 123 from a port connected to the switch leads you always to the switch, not the actual target.</p>
  </li>
  <li>
    <p>VLAN configuration cannot be saved when under load (only affecting Sirivision)</p>

    <p>If there’s already traffic in a VLAN that’s not fully configured, you can never get it fully configured. The web UI just freezes and config would never be saved. You can only configure things in one go.</p>

    <p>This makes it very inconvenient if you want your main VLAN not being VLAN 1, and to make things worse recall that “VLAN 1 cannot be safely deleted” and “Both of these seem to be using VLAN 1 for their internal logic”, so in general you’d better not be using VLAN 1 and have to do this. To work around this and make your VLANs seperate from the default VLAN 1 hassle you have to configure ports in batch but not including your currently used port, and then swap ports around and set the remaining port. And sometimes you cannot set the remaining ports and have to delete the VLAN and re-create it.</p>
  </li>
  <li>
    <p>VLAN and port naming has no memory boundary check</p>

    <p>While it appears the VLAN IDs and ports can be named for lookup, setting them sometimes result in success but most time result in glitched characters. And when they’re  glitched some other settings might get flushed. This is most likely a memory boundary checking issue and when it happens your only reliable fix is to revert everything, most likely to factory reset.</p>
  </li>
  <li>
    <p>Under specific workload the switch would power cycle (only affecting Hellotek)</p>

    <p>I’m using a single port on one of the Hellotek switch as an upstream trunk port to connect to the ISP modem, carrying both PPPoE Internet upstream in a VLAN and IPoE IPTV upstream in another VLAN, the IPTV VLAN is then carried in trunk through another Hellotek switch, then in trunk through a K2P running Openwrt acting as both switch and AP. Strangely the main switch i.e. the one connected directly to the modem would reset itself when IPTV stream start to run. E.g. if I don’t watch TV then everything seems fine, if I start watching TV and the IPTV set-top box initiated its IPoE stream then after a couple of minutes the main switch would reset, cutting both my Internet and IPTV connections. If they keep running for a few minutes after the switch comes back online then it would not reset any more, very very strange.</p>

    <p>To get away from this issue I had to replace the main switch to a 10 Gbps L3 switch and add a 2.5 Gbps base-T SFP+ module.</p>
  </li>
</ol>

<p>So while these switches seem cheap, be prepared for various issues if you decide to use them for fancy network setups.</p>]]></content><author><name></name></author><category term="networking" /><summary type="html"><![CDATA[RTL8373 is great: it’s the cheapest L2 network switching IC that provides 8x 2.5 Gbps ethernet ports (4 with its built-in PHY, 4 with external PHYs from RTL8224), 1 10 Gbps SFP port, and on top of them fancy L2 management features like VLAN, Link aggregation, etc.]]></summary></entry><entry><title type="html">Btrfs backup: snapper + snasync</title><link href="https://7ji.github.io/designdoc/2025/01/09/btrfs-backup-snapper-with-snasync.html" rel="alternate" type="text/html" title="Btrfs backup: snapper + snasync" /><published>2025-01-09T03:15:00+00:00</published><updated>2025-01-09T03:15:00+00:00</updated><id>https://7ji.github.io/designdoc/2025/01/09/btrfs-backup-snapper-with-snasync</id><content type="html" xml:base="https://7ji.github.io/designdoc/2025/01/09/btrfs-backup-snapper-with-snasync.html"><![CDATA[<p>Usually I don’t like to document about my software/script projects on the blog as I prefer to docuemnt them in tree. But as I’ve been a Btrfs user for multiple years and haven’t really documented the details I’d like to share, I think it’s a good time to share them together here.</p>

<h2 id="background-btrfs">Background: Btrfs</h2>

<p>I’ll assume you’ve already known what Btrfs is: a copy-on-write next-gen Linux filesystem that has entered the kernel for a long time and has many shiny features, of which I appreciate snapshoting and transparent compression the most.</p>

<p>Thanks to Btrfs’s nature of copy-on-write, to its helpful userspace toolset btrfs-progs, and to the fact that Btrfs subvolumes live in the filesystem namespace and the user perspective as if they’re just folders, it is travail to snapshot a Btrfs filesystem or part of it.</p>

<p>With the added benefit that a Btrfs subvolume can be mounted directly with specified <code class="language-plaintext highlighter-rouge">subvol=</code> or <code class="language-plaintext highlighter-rouge">subvolid=</code> mount argument and their content cannot be snapshotted as a part of the parent folder / subvlume they live in, you can combine your filesystem tree freely to decide which part to take snapshots on.</p>

<p>However, although it is travail to take a snapshot, it is hard to do it cleanly reguarlarly with handwritten script and crontab jobs / systemd.timer units that are easy to forget about and prune to fail. After all, if you’ve deleted a file by accident you’ve just created a few hours ago yet your last snapshot are taken a week ago, you’re doomed just like you don’t have snapshots / backup.</p>

<p>And of course you would want snapshots to be cleaned reguarly as few would really need a snapshot taken 3 months and 1 hour ago more than a snapshot taken exactly 3 months ago.</p>

<h2 id="background-snapper">Background: snapper</h2>

<p>I’ll also assume you’ve already known what snapper is: a tool written by OpenSUSE to manage filesystem snapshots and allow undo of system modifications.</p>

<p>To describe it simply: with snapper installed, and its bundled timer units enabled, you can define a few configs that each define what subvolume to take snapshots regurlarly on, and snapper would create and clean up snapshots, and manage them in a centralized way under the corresponding <code class="language-plaintext highlighter-rouge">.snapshots</code> submount.</p>

<p>Let me take a few lines from my fstab to demonstrate how it works (only the mountpoints of the subvolume to take snapshots and their corresponding snapshots storage subvolume are listed, others, like <code class="language-plaintext highlighter-rouge">/var/cache</code> are left out):</p>
<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># &lt;file system&gt; &lt;dir&gt; &lt;type&gt; &lt;options&gt; &lt;dump&gt; &lt;pass&gt;
## backpane m.2 2280 (2T 4.0 x4 downgraded to 3.0 x4)
</span><span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX4_GR__24113WJHA0000072</span>-<span class="n">part3</span> /                      <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">3</span>,<span class="n">subvol</span>=@                       <span class="m">0</span> <span class="m">0</span>
<span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX4_GR__24113WJHA0000072</span>-<span class="n">part3</span> /.<span class="n">snapshots</span>            <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">3</span>,<span class="n">subvol</span>=@.<span class="n">snapshots</span>             <span class="m">0</span> <span class="m">0</span>
<span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX4_GR__24113WJHA0000072</span>-<span class="n">part3</span> /<span class="n">home</span>                  <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">3</span>,<span class="n">subvol</span>=@<span class="n">home</span>                   <span class="m">0</span> <span class="m">0</span>
<span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX4_GR__24113WJHA0000072</span>-<span class="n">part3</span> /<span class="n">home</span>/.<span class="n">snapshots</span>       <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">3</span>,<span class="n">subvol</span>=@<span class="n">home_</span>.<span class="n">snapshots</span>        <span class="m">0</span> <span class="m">0</span>
<span class="c">## expansion lower m.2 22110 (1T 3.0 x4)
</span><span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">MZ1LB960HBJR</span>-<span class="m">000</span><span class="n">FB_S5XBNA0R330322</span>   /<span class="n">srv</span>                   <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">15</span>,<span class="n">subvol</span>=@<span class="n">srv</span>                   <span class="m">0</span> <span class="m">0</span>
<span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">MZ1LB960HBJR</span>-<span class="m">000</span><span class="n">FB_S5XBNA0R330322</span>   /<span class="n">srv</span>/.<span class="n">snapshots</span>        <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">15</span>,<span class="n">subvol</span>=@<span class="n">srv_</span>.<span class="n">snapshots</span>        <span class="m">0</span> <span class="m">0</span>
<span class="c">## frontpane m.2 2280 (2T 3.0 x4)
</span><span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX3_HXY__00000000000000001116</span>  /<span class="n">srv</span>/<span class="n">backup</span>            <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">15</span>,<span class="n">subvol</span>=@<span class="n">srv_backup</span>            <span class="m">0</span> <span class="m">0</span>
<span class="n">ID</span>=<span class="n">nvme</span>-<span class="n">HYV2TBX3_HXY__00000000000000001116</span>  /<span class="n">srv</span>/<span class="n">backup</span>/.<span class="n">snapshots</span> <span class="n">btrfs</span> <span class="n">rw</span>,<span class="n">compress</span>=<span class="n">zstd</span>:<span class="m">15</span>,<span class="n">subvol</span>=@<span class="n">srv_backup_</span>.<span class="n">snapshots</span> <span class="m">0</span> <span class="m">0</span>
</code></pre></div></div>
<p>Recall that a Btrfs subvolume’s content cannot be snapshotted as a part of the parent folder / subvlume they live in, the reason we have <code class="language-plaintext highlighter-rouge">/</code>, <code class="language-plaintext highlighter-rouge">/home</code>, <code class="language-plaintext highlighter-rouge">/srv</code>, <code class="language-plaintext highlighter-rouge">/srv/backup</code> as seperate mountpoints is to have them each snapshotted individually into their <code class="language-plaintext highlighter-rouge">.snapshot</code> subfolder / child subvolume, and <code class="language-plaintext highlighter-rouge">*/.snapshots</code> are also their seperate subvolumes so their content won’t be snapshotted as part of their parent.</p>

<p>As Btrfs subvolumes can totoally just exist in the FS tree accessible to users as if they’re plain folders that just cannot be snapshooted as part of their parent subvolume, it is also possible to omit all these mountpoints and to have a single root subvolume mountpoint, but in that case it would be hard to follow which folder is in fact a subvolume unless you use <code class="language-plaintext highlighter-rouge">btrfs-progs</code> and corresponding commands. As always I prefer explicity over implicity, so I always set up Btrfs subvolumes directly in the FS root and mount them each individually into the actual root. You can freely decide which way to follow.</p>

<p>I have the following in my <code class="language-plaintext highlighter-rouge">/etc/conf.d/snapper</code>:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">SNAPPER_CONFIGS</span><span class="o">=</span><span class="s2">"root home srv srv-backup"</span>
</code></pre></div></div>

<p>Are I have the following snapper configs under <code class="language-plaintext highlighter-rouge">/etc/snapper/configs/</code>:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">ls</span> <span class="nt">-lh</span> /etc/snapper/configs/
total 20K
<span class="nt">-rw-------</span> 1 root root 1.2K Jul  3  2024 home
<span class="nt">-rw-------</span> 1 root root 1.2K Jul  3  2024 root
<span class="nt">-rw-------</span> 1 root root 1.2K Jul 29 16:41 srv
<span class="nt">-rw-------</span> 1 root root 1.2K Jan  3 14:22 srv-backup
</code></pre></div></div>

<p>An example config, <code class="language-plaintext highlighter-rouge">home</code>, is defined as follows:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># subvolume to snapshot
SUBVOLUME="/home"

# filesystem type
FSTYPE="btrfs"


# btrfs qgroup for space aware cleanup algorithms
QGROUP=""


# fraction or absolute size of the filesystems space the snapshots may use
SPACE_LIMIT="0.5"

# fraction or absolute size of the filesystems space that should be free
FREE_LIMIT="0.2"


# users and groups allowed to work with config
ALLOW_USERS=""
ALLOW_GROUPS=""

# sync users and groups from ALLOW_USERS and ALLOW_GROUPS to .snapshots
# directory
SYNC_ACL="no"


# start comparing pre- and post-snapshot in background after creating
# post-snapshot
BACKGROUND_COMPARISON="yes"


# run daily number cleanup
NUMBER_CLEANUP="yes"

# limit for number cleanup
NUMBER_MIN_AGE="1800"
NUMBER_LIMIT="50"
NUMBER_LIMIT_IMPORTANT="10"


# create hourly snapshots
TIMELINE_CREATE="yes"

# cleanup hourly snapshots after some time
TIMELINE_CLEANUP="yes"

# limits for timeline cleanup
TIMELINE_MIN_AGE="1800"
TIMELINE_LIMIT_HOURLY="12"
TIMELINE_LIMIT_DAILY="3"
TIMELINE_LIMIT_WEEKLY="2"
TIMELINE_LIMIT_MONTHLY="6"
TIMELINE_LIMIT_YEARLY="1"


# cleanup empty pre-post-pairs
EMPTY_PRE_POST_CLEANUP="yes"

# limits for empty pre-post-pair cleanup
EMPTY_PRE_POST_MIN_AGE="1800"
</code></pre></div></div>

<p>With the above config, and the bundled <code class="language-plaintext highlighter-rouge">snapper-timeline.timer</code> and <code class="language-plaintext highlighter-rouge">snapper-cleanup.timer</code> systemd units enabled, snapper handles the <code class="language-plaintext highlighter-rouge">/home</code> subvolume as follows:</p>

<ul>
  <li>Every hour, run <code class="language-plaintext highlighter-rouge">snapper-timeline.service</code> to take a snapshot of <code class="language-plaintext highlighter-rouge">/home</code> and store it under <code class="language-plaintext highlighter-rouge">/home/.snapshots/</code>
    <ul>
      <li>A new snapshot ID would be generated incrementally, larger than all existing snapshot IDs, e.g. if there are <code class="language-plaintext highlighter-rouge">1</code>, <code class="language-plaintext highlighter-rouge">2</code>, <code class="language-plaintext highlighter-rouge">135</code>, <code class="language-plaintext highlighter-rouge">555</code>, <code class="language-plaintext highlighter-rouge">705</code>, then the new ID would be <code class="language-plaintext highlighter-rouge">706</code></li>
      <li>A new folder e.g. <code class="language-plaintext highlighter-rouge">/home/.snapshots/706</code> would be created as the snapper snapshot container (It’s only my own calling, I don’t know how snapper calls them)</li>
      <li>A Btrfs snapshot of subvolume <code class="language-plaintext highlighter-rouge">/home</code> would be created under the container folder, e.g. <code class="language-plaintext highlighter-rouge">/home/.snapshots/237/snapshot</code></li>
      <li>
        <p>The corresponding metadata would be stored under the container folder, e.g. <code class="language-plaintext highlighter-rouge">/home/.snapshots/237/info.xml</code>, with content like the following:</p>

        <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;?xml version="1.0"?&gt;
&lt;snapshot&gt;
  &lt;type&gt;single&lt;/type&gt;
  &lt;num&gt;706&lt;/num&gt;
  &lt;date&gt;2024-12-22 16:00:05&lt;/date&gt;
  &lt;description&gt;timeline&lt;/description&gt;
  &lt;cleanup&gt;timeline&lt;/cleanup&gt;
  &lt;/snapshot&gt;
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Every hour, run <code class="language-plaintext highlighter-rouge">snapper-cleanup.service</code> to clean up snapshots under <code class="language-plaintext highlighter-rouge">/home/.snapshots/</code>
    <ul>
      <li>As <code class="language-plaintext highlighter-rouge">TIMELINE_LIMITE_HOURLY="12"</code>, only keep 12 hourly snapshots taken most recently (current, current - 1, … current - 11; but skip the one taken as 00:00 as it would be considered daily)</li>
      <li>Likely, only keep 3 daily snapshots taken most recently (skip weekly)</li>
      <li>Likely, only keep 2 weekly snapshots taken most recently (skip monthly)</li>
      <li>…</li>
      <li>Remove all snapshots that shall not be kept</li>
    </ul>
  </li>
  <li>If explicitly required, snapshots can be manually created by <code class="language-plaintext highlighter-rouge">snapper create (-c [config])</code>, e.g. <code class="language-plaintext highlighter-rouge">snapper create -c home</code>, and manually removed by <code class="language-plaintext highlighter-rouge">snapper delete (-c [config]) [snapshot ID]</code>, e.g. <code class="language-plaintext highlighter-rouge">snapper delete -c home 1</code></li>
</ul>

<p>With the above setup, the folder <code class="language-plaintext highlighter-rouge">/home/.snapshots</code> would look like the following after running snapper for a long time:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -lh /home/.snapshots/
total 100K
drwxr-xr-x 1 root root 32 Nov 22 18:00 1/
drwxr-xr-x 1 root root 32 Jan  5 00:00 1003/
drwxr-xr-x 1 root root 32 Jan  6 00:00 1027/
drwxr-xr-x 1 root root 32 Jan  7 00:00 1051/
drwxr-xr-x 1 root root 32 Jan  7 12:00 1063/
drwxr-xr-x 1 root root 32 Jan  7 13:00 1064/
drwxr-xr-x 1 root root 32 Jan  7 14:00 1065/
drwxr-xr-x 1 root root 32 Jan  7 15:00 1066/
drwxr-xr-x 1 root root 32 Jan  7 16:00 1067/
drwxr-xr-x 1 root root 32 Jan  7 17:00 1068/
drwxr-xr-x 1 root root 32 Jan  7 18:00 1069/
drwxr-xr-x 1 root root 32 Jan  7 19:00 1070/
drwxr-xr-x 1 root root 32 Jan  7 20:00 1071/
drwxr-xr-x 1 root root 32 Jan  7 21:00 1072/
drwxr-xr-x 1 root root 32 Jan  7 22:00 1073/
drwxr-xr-x 1 root root 32 Jan  7 23:00 1074/
drwxr-xr-x 1 root root 32 Jan  8 00:00 1075/
drwxr-xr-x 1 root root 32 Jan  8 01:00 1076/
drwxr-xr-x 1 root root 32 Jan  8 02:00 1077/
drwxr-xr-x 1 root root 32 Jan  8 03:00 1078/
drwxr-xr-x 1 root root 32 Jan  8 04:00 1079/
drwxr-xr-x 1 root root 32 Jan  8 05:00 1080/
drwxr-xr-x 1 root root 32 Jan  8 06:00 1081/
drwxr-xr-x 1 root root 32 Jan  8 07:00 1082/
drwxr-xr-x 1 root root 32 Jan  8 08:00 1083/
drwxr-xr-x 1 root root 32 Jan  8 09:00 1084/
drwxr-xr-x 1 root root 32 Jan  8 10:00 1085/
drwxr-xr-x 1 root root 32 Jan  8 11:00 1086/
drwxr-xr-x 1 root root 32 Dec  1 00:00 199/
drwxr-xr-x 1 root root 32 Dec 16 17:32 555/
drwxr-xr-x 1 root root 32 Dec 23 00:00 706/
drwxr-xr-x 1 root root 32 Dec 30 00:00 874/
drwxr-xr-x 1 root root 32 Jan  1 00:00 922/
drwxr-xr-x 1 root root 32 Jan  3 10:00 965/
drwxr-xr-x 1 root root 32 Jan  4 00:00 979/
</code></pre></div></div>

<p>An example snapper snapshot container folder <code class="language-plaintext highlighter-rouge">/home/.snapshots/706/</code> would look like the following:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -lh /home/.snapshots/706
total 4.0K
-rw-r--r-- 1 root root 187 Dec 23 00:00 info.xml
drwxr-xr-x 1 root root  36 Aug 26 15:56 snapshot/
</code></pre></div></div>

<p>We can confirm <code class="language-plaintext highlighter-rouge">/home/.snapshots/706/snapshot</code> is indeed a Btrfs snapshot / read-only subvolume</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; btrfs subvolume show /home/.snapshots/706/snapshot/
@home_.snapshots/706/snapshot
        Name:                   snapshot
        UUID:                   b54462aa-3427-c745-8fcd-ac248143a039
        Parent UUID:            0ce1faab-e8db-e94f-a3c8-be85743e5859
        Received UUID:          -
        Creation time:          2024-12-23 00:00:05 +0800
        Subvolume ID:           2265
        Generation:             167279
        Gen at creation:        167278
        Parent ID:              259
        Top level ID:           259
        Flags:                  readonly
        Send transid:           0
        Send time:              2024-12-23 00:00:05 +0800
        Receive transid:        0
        Receive time:           -
        Snapshot(s):
        Quota group:            n/a
</code></pre></div></div>

<p>and its content is really what was in <code class="language-plaintext highlighter-rouge">/home</code> as that point:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -alh /home/.snapshots/706/snapshot
total 0
drwxr-xr-x 1 root     root       36 Aug 26 15:56 ./
drwxr-xr-x 1 root     root       32 Dec 23 00:00 ../
drwxr-xr-x 1 root     root        0 Apr  8  2024 .snapshots/
drwx------ 1 nomad7ji nomad7ji 1.3K Dec 22 22:30 nomad7ji/
</code></pre></div></div>

<p>and confirming what we said above, <code class="language-plaintext highlighter-rouge">.snapshots</code> as a mountpoint would not be recursively snapshotted:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -alh /home/.snapshots/706/snapshot/.snapshots
total 0
drwxr-xr-x 1 root root  0 Apr  8  2024 ./
drwxr-xr-x 1 root root 36 Aug 26 15:56 ../
</code></pre></div></div>

<p>A minor thing to note there, is that snapper does not have its “database” that live outside of the subvolume and the container collection, it just calculates what’re already been created to decide new things such as new IDs. This is a thing I appreciate, as everything lives inside the snapshot container itself and you do not need extra data or even snapper itself to recover them. But this also has a “side-effect”: the ID is not strictly incremental, if you remove e.g. 565, 567, 900, 901, then the new ID could be 565, and if you remove all snapshots then they restart from ID 1 again.</p>

<h2 id="data-robustness">Data robustness</h2>

<p>Btrfs itself provides you with online data robustness: as long as it runs with CoW feature not turned off then it scrubs and checks data corruption like bitrots reguarlly, and it can report the corruption right away. It’s also not easy to be bricked unless you play with RAID56 which is discouraged by Btrfs developers. However, unless you set it up in a Btrfs native RAID1 or alike setup, the corruption cannot be fixed unless you have a backup, as the unique data has only one copy.</p>

<p>snapper enhances the online data robustness by providing the possibility to roll back: it avoids the data loss caused by either accidental, harmful or then-intentional-now-regretful operations. It has saved my ass for multiple times when I accidentally delete my work files, and thankfully I could get the last hourly snapshot to at least restore some of my work. However, as it only snapshots a subvolume on a single filesystem into subvolumes on the same exact filesytem, it is only a hot backup solution and won’t do magic if the FS itself breaks.</p>

<p>Both Btrfs itself and snapper lives on the hot, currently active storage, and if the underlying drives dies, they can’t have the magic to repair themselves and the data that die along with them.</p>

<p>For complete data robustness you need layered backup, and a solution to do warm/cold backup, which should in most cases be offline. One famous backup strategy that I follow is 3-2-1: to maintain 3 copies of data, to use 2 different types of media, and to keep at least 1 copy off-site.</p>

<p>With the plain Btrfs + snapper setup we already have 2 copies of data: 1 real-time and more than 1 copies in snapshots, I consider the “more than 1 copies in snapshots” part as 1, as the marginal effect decreases fast for the snapshots. What we miss is the last 1 off-site copy of data that’s stored on a different type of media.</p>

<h2 id="snasync">snasync</h2>

<p>When someone has already defined a good design, it’s better to follow it and improve it, rather than throw it away and start all over. As we already have snapper that does the online snapshots creation and cleaning, the best way to have an off-site backup is simply to backup the snapshots to another Btrfs storage so we can have the same robustness as snapper snapshots.</p>

<p>Introducing snasync, a snapper Btrfs snapshots syncer, to backup your Btrfs snapshots created by snapper to warm or cold, and in most cases remote storage.</p>

<p>snasync does one thing and one thing naively: to sync the snapper containers (my own calling, those folders that live under <code class="language-plaintext highlighter-rouge">*/.snapshots</code> and containing <code class="language-plaintext highlighter-rouge">info.xml</code> and <code class="language-plaintext highlighter-rouge">snapshot</code>) one way to “targets”: either local, or remote.</p>

<p>As the snapper container ID is only useful to online snapper operations, not strictly incremental, and not meaningful on the timeline perspective, snasync sync local snapper containers to remote in <code class="language-plaintext highlighter-rouge">[prefix]-[timestamp]</code> name style. An example syncing map is as follows:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/home/.snapshots        -&gt; snasync@nas.lan:/srv/backup/snapshots
  - /home/.snapshots/1  -&gt;  - snasync@nas.lan:/srv/backup/snapshots/pc_home-20241101030001
  - /home/.snapshots/2  -&gt;  - snasync@nas.lan:/srv/backup/snapshots/pc_home-20241102030001
  - /home/.snapshots/13 -&gt;  - snasync@nas.lan:/srv/backup/snapshots/pc_home-20241106030001
  ...
</code></pre></div></div>

<p>In which, only <code class="language-plaintext highlighter-rouge">1</code> would be sent as a whole subvolume, and <code class="language-plaintext highlighter-rouge">2</code> would be sent as its parent defined as <code class="language-plaintext highlighter-rouge">1</code>, <code class="language-plaintext highlighter-rouge">13</code> would be sent as its parent defined as <code class="language-plaintext highlighter-rouge">2</code>, etc.</p>

<p>Don’t worry about the snapper metadata as they’re still there:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -lh /srv/backup/snapshots/rz5_root-20241122100006/
total 4.0K
-rw-r--r-- 1 root root 185 Jan  3 14:56 info.xml
drwxr-xr-x 1 root root 150 Jan  3 14:56 snapshot/
&gt; cat /srv/backup/snapshots/rz5_root-20241122100006/info.xml
&lt;?xml version="1.0"?&gt;
&lt;snapshot&gt;
  &lt;type&gt;single&lt;/type&gt;
  &lt;num&gt;1&lt;/num&gt;
  &lt;date&gt;2024-11-22 10:00:06&lt;/date&gt;
  &lt;description&gt;timeline&lt;/description&gt;
  &lt;cleanup&gt;timeline&lt;/cleanup&gt;
&lt;/snapshot&gt;
</code></pre></div></div>

<p>The timestamp is extracted from the metadata and explicited included in the name so it’s easier to look up without the help of snapper.</p>

<p>An example snasync run is as follows:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">targets</span><span class="o">=(</span>
    <span class="nt">--target</span> /srv/backup/snapshots <span class="c"># a simple path refers to local target, this is my online warm backup</span>
    <span class="nt">--target</span> snasync@wtr.fuo.lan:/srv/backup/snapshots <span class="c"># a scp-style target means to sync to remote, this is my off-site cool backup (not entirely cold as not off-line)</span>
<span class="o">)</span>
snasync <span class="se">\</span>
    <span class="nt">--source</span> / <span class="nt">--prefix</span> rz5_root <span class="s2">"</span><span class="k">${</span><span class="nv">targets</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nt">--source</span> /home <span class="nt">--prefix</span> rz5_home <span class="s2">"</span><span class="k">${</span><span class="nv">targets</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nt">--source</span> /srv <span class="nt">--prefix</span> rz5_srv <span class="s2">"</span><span class="k">${</span><span class="nv">targets</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>

<p>Basically you define a <code class="language-plaintext highlighter-rouge">source</code> to sync from (either the subvolume containing <code class="language-plaintext highlighter-rouge">.snapshots</code>, or <code class="language-plaintext highlighter-rouge">.snapshots</code> itself, snasync would figure it out), and the prefix to name the synced snapshots with (otherwise would be derived from source path), and then a few targets to sync them to, each source has their own targets, but in this case they’re all the same.</p>

<p>The whole operating logic of snasync is as follows:</p>
<ul>
  <li>If <code class="language-plaintext highlighter-rouge">snapper-cleanup.timer</code> is running, stop it and register an on-exit trigger to start it again.</li>
  <li>If <code class="language-plaintext highlighter-rouge">snapper-cleanup.service</code> is running, wait for it to finish.</li>
  <li>Scan for all sources to get a list of snapper containers to sync, the list would not be updated during this run again are is essentially read-only.
    <ul>
      <li>If a snapshot is read-write, skip it</li>
      <li>If <code class="language-plaintext highlighter-rouge">info.xml</code> is missing, skip it</li>
      <li>Timestamp is extracted from <code class="language-plaintext highlighter-rouge">info.xml</code> and used as key</li>
      <li>A timestamp-name map, a timestamp-path map are each created with timestamp with key</li>
      <li>Timestamps are sorted so later operations go from the minimum to the maximum</li>
    </ul>
  </li>
  <li>Iterate through all targets to extract the list of remotes, and do simple SSH connection to each of them with control master specified and quit to warm up the connection.
    <ul>
      <li>If a remote is out of reach, mark it as bad, and it would not be used in later syncing</li>
    </ul>
  </li>
  <li>For each source, fork out to sync it so we’re syncing with multi-process</li>
  <li>In the source syncer, for each target, for out to sync it so we’re syncing with multi-process</li>
  <li>Iterate through the corresponding snapper containers, for each of them:
    <ul>
      <li>If a snapshot was already synced, define parent as the last snapshot, otherwise keep it empty</li>
      <li>If a container exist in target:
        <ul>
          <li>If <code class="language-plaintext highlighter-rouge">snapshot</code> is read-only or contains received-UUID:
            <ul>
              <li>If <code class="language-plaintext highlighter-rouge">info.xml</code> does not exist, copy the source one to it</li>
              <li>Consider this container already synced and skip to the next one</li>
            </ul>
          </li>
          <li>If <code class="language-plaintext highlighter-rouge">snapshot</code> is missing, or not read-only, or does not contain received-UUUID:
            <ul>
              <li>Delete the <code class="language-plaintext highlighter-rouge">snapshot</code> and <code class="language-plaintext highlighter-rouge">info.xml</code></li>
            </ul>
          </li>
        </ul>
      </li>
      <li>If a container does not exist in target, create the container</li>
      <li>Sync the source <code class="language-plaintext highlighter-rouge">snapshot</code> to target
        <ul>
          <li>Sender has argument <code class="language-plaintext highlighter-rouge">--compressed-data</code> to keep the compressed data to save bandwidth, even if it’s running a local sync</li>
          <li>Sender has argument <code class="language-plaintext highlighter-rouge">--parent [snapshot]</code> if we have already seen synced snapshot to do only incremental sync, otherwise it does not have such argument</li>
          <li>Receiver has argument <code class="language-plaintext highlighter-rouge">--force-decompress</code> to decompress the data first and then compress with the target compression options</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Iterate through target containers that start with the prefix and named in expected format
    <ul>
      <li>If it’s seen at source, keep it untouched</li>
      <li>If it’s not seen at source, rename it to add <code class="language-plaintext highlighter-rouge">.orphan</code> suffix. It’s up to users whether to delete or keep them.</li>
    </ul>
  </li>
  <li>In the source syncer, collect target syncers</li>
  <li>In the main worker, collect source syncers</li>
  <li>Bring back <code class="language-plaintext highlighter-rouge">snapper-cleanup.timer</code> if this was registered</li>
</ul>

<p>On my remote backup server the layout looks like the following:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -l /srv/backup/snapshots/
total 0
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241126130020/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241224130016/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241224140003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241225160003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241225170003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241226130022/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241226140022/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241227160003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241227170003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241227180003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241228150001/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241228160001/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_home-20241228170003/
drwxr-xr-x 1 root root 32 Dec 29 17:55 dsk_root-20241126130020/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241224130016/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241224140003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241225160003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241225170003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241226130022/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241226140022/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241227160003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241227170003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241227180003/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241228150001/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241228160001/
drwxr-xr-x 1 root root 32 Dec 29 17:57 dsk_root-20241228170003/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20240920170000/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20240930160000/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241031160001/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241130160020/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241208160007/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241215160017/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241217160023.orphan/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241218160025.orphan/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241219160025/
drwxr-xr-x 1 root root 32 Dec 23 21:36 fuo_home-20241220160005/
......
drwxr-xr-x 1 root root 32 Dec 24 14:48 wtr_root-20241224040006/
drwxr-xr-x 1 root root 32 Dec 24 14:48 wtr_root-20241224050016/
drwxr-xr-x 1 root root 32 Dec 24 14:48 wtr_root-20241224060000/
</code></pre></div></div>

<p>As the snapshots are sent and received with parent if possible, and compressed on target which shall be mounted with higher compression level than source, the disk space they take is not very much:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; sudo btrfs filesystem du -s /srv/backup/snapshots/*
     Total   Exclusive  Set shared  Filename
  25.81GiB    28.26MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241126130020
  25.80GiB     4.57MiB    18.97GiB  /srv/backup/snapshots/dsk_home-20241224130016
  25.79GiB     7.00MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241224140003
  25.79GiB     1.47MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241225160003
  25.79GiB     1.57MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241225170003
  25.79GiB     3.44MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241226130022
  25.79GiB     4.17MiB    18.96GiB  /srv/backup/snapshots/dsk_home-20241226140022
  ...
</code></pre></div></div>

<p>Note the snasync only renames the containers that do not exist locally to have a <code class="language-plaintext highlighter-rouge">.orphan</code> suffix but not delete them, their content, <code class="language-plaintext highlighter-rouge">snapshot</code> and <code class="language-plaintext highlighter-rouge">info.xml</code> are still there:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; ls -lh /srv/backup/snapshots/fuo_home-20241217160023.orphan/
total 4.0K
-rw-r--r-- 1 root root 188 Dec 23 21:43 info.xml
drwxr-xr-x 1 root root  36 Jul  9  2024 snapshot/
</code></pre></div></div>

<p>One can remove all orhpaned containers and snapshots in them if they want to free up space:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&gt; sudo btrfs subvolume delete /srv/backup/snapshots/*.orphan/snapshot
&gt; sudo rm -rf /srv/backup/snapshots/*.orphan
</code></pre></div></div>

<p>When backed up data is needed one can simply navigate through all the containers and snapshots</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nomad7ji@wtr /s/b/snapshots&gt; cd rz5_home-20241122100006/
nomad7ji@wtr /s/b/s/rz5_home-20241122100006&gt; ls
info.xml  snapshot/
nomad7ji@wtr /s/b/s/rz5_home-20241122100006&gt; cd snapshot/
nomad7ji@wtr /s/b/s/r/snapshot&gt; ls
nomad7ji/
nomad7ji@wtr /s/b/s/r/snapshot&gt; cd nomad7ji/
nomad7ji@wtr /s/b/s/r/s/nomad7ji&gt; ls
Android/  Building/  Desktop/  Development/  Documents/  Downloads/  go/  Music/  opt/  Pictures/  Public/  Security/  Templates/  Videos/
</code></pre></div></div>

<h2 id="offline-cold-backup-is-still-needed">Offline cold backup is still needed</h2>

<p>While Btrfs + snapper + snasync setup provides enough robustness, all of them relies on the robustness of Btrfs filesystem, and it’s not impossible to fail, and you can not trust the underlying storage 100%.</p>

<p>When possible, please do regular offline cold backup. I do yearly BD-R backups with 25GB HTL BD-R disces with 10% parity volume, and hoard the disces at my parents’ and the parity volumes on network drive. It does not matter if the data is hard to retrieve, so it’s even OK to store it on e.g. AWS S3. They’re only needed when everything else fails, and at that point, every part of the work to retrieve them are always worth it.</p>]]></content><author><name></name></author><category term="designdoc" /><summary type="html"><![CDATA[Usually I don’t like to document about my software/script projects on the blog as I prefer to docuemnt them in tree. But as I’ve been a Btrfs user for multiple years and haven’t really documented the details I’d like to share, I think it’s a good time to share them together here.]]></summary></entry><entry><title type="html">Bash logging, an improvded way</title><link href="https://7ji.github.io/scripting/2024/12/27/bash-logging-improved.html" rel="alternate" type="text/html" title="Bash logging, an improvded way" /><published>2024-12-27T09:15:00+00:00</published><updated>2024-12-27T09:15:00+00:00</updated><id>https://7ji.github.io/scripting/2024/12/27/bash-logging-improved</id><content type="html" xml:base="https://7ji.github.io/scripting/2024/12/27/bash-logging-improved.html"><![CDATA[<p>Three months ago I’ve written <a href="../../09/29/bash-logging-with-funcname-lineno.html">a blog post</a> to document how to print logs in Bash with function names and line numbers just like in C, however the method documented there relied on <code class="language-plaintext highlighter-rouge">eval</code> and is not very clean. A few weeks ago I found a better way to do this and I just had time to write it down.</p>

<p>Let’s start this with a full demo:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>log_inner<span class="o">()</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">log_enabled</span><span class="p">[</span><span class="nv">$1</span><span class="p">]</span><span class="k">}</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"[</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">##*/</span><span class="k">}</span><span class="s2">:</span><span class="k">${</span><span class="nv">1</span><span class="p">^^</span><span class="k">}</span><span class="s2">] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="p">[2]</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">BASH_LINENO</span><span class="p">[1]</span><span class="k">}</span><span class="s2">: </span><span class="k">${</span><span class="p">*</span>:2<span class="k">}</span><span class="s2">"</span>
    <span class="k">fi</span>
<span class="o">}</span>

log_debug<span class="o">()</span> <span class="o">{</span>
    log_inner debug <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_info<span class="o">()</span> <span class="o">{</span>
    log_inner info <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_warn<span class="o">()</span> <span class="o">{</span>
    log_inner warn <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_error<span class="o">()</span> <span class="o">{</span>
    log_inner error <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_fatal<span class="o">()</span> <span class="o">{</span>
    log_inner fatal <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

initialize<span class="o">()</span> <span class="o">{</span>
    <span class="nb">set</span> <span class="nt">-euo</span> pipefail
    <span class="nb">declare</span> <span class="nt">-gA</span> <span class="nv">log_enabled</span><span class="o">=(</span>
        <span class="o">[</span>debug]<span class="o">=</span><span class="s1">'y'</span>
        <span class="o">[</span>info]<span class="o">=</span><span class="s1">'y'</span>
        <span class="o">[</span>warn]<span class="o">=</span><span class="s1">'y'</span>
        <span class="o">[</span>error]<span class="o">=</span><span class="s1">'y'</span>
        <span class="o">[</span>fatal]<span class="o">=</span><span class="s1">'y'</span>
    <span class="o">)</span>
    <span class="nb">local </span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">:-</span><span class="nv">info</span><span class="k">}</span><span class="s2">"</span>
    <span class="k">case</span> <span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="p">,,</span><span class="k">}</span><span class="s2">"</span> <span class="k">in</span>
    <span class="s1">'debug'</span><span class="p">)</span>
        :
        <span class="p">;;</span>
    <span class="s1">'info'</span><span class="p">)</span>
        log_enabled[debug]<span class="o">=</span><span class="s1">''</span>
        <span class="p">;;</span>
    <span class="s1">'warn'</span><span class="p">)</span>
        log_enabled[debug]<span class="o">=</span><span class="s1">''</span>
        log_enabled[info]<span class="o">=</span><span class="s1">''</span>
        <span class="p">;;</span>
    <span class="s1">'error'</span><span class="p">)</span>
        log_enabled[debug]<span class="o">=</span><span class="s1">''</span>
        log_enabled[info]<span class="o">=</span><span class="s1">''</span>
        log_enabled[warn]<span class="o">=</span><span class="s1">''</span>
        <span class="p">;;</span>
    <span class="s1">'fatal'</span><span class="p">)</span>
        log_enabled[debug]<span class="o">=</span><span class="s1">''</span>
        log_enabled[info]<span class="o">=</span><span class="s1">''</span>
        log_enabled[warn]<span class="o">=</span><span class="s1">''</span>
        log_enabled[error]<span class="o">=</span><span class="s1">''</span>
        <span class="p">;;</span>
    <span class="k">*</span><span class="p">)</span>
        log_fatal <span class="s2">"Unknown log level </span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">}</span><span class="s2">, shall be one of the"</span><span class="se">\</span>
            <span class="s2">"following (case-insensitive): debug, info, warn, error, fatal"</span>
        <span class="k">return </span>1
        <span class="p">;;</span>
    <span class="k">esac</span>
<span class="o">}</span>

work<span class="o">()</span> <span class="o">{</span>
    log_warn <span class="s2">"Started working..."</span>
    log_fatal <span class="s2">"A fatal error occured!"</span>
<span class="o">}</span>

main<span class="o">()</span> <span class="o">{</span>
    initialize
    log_info <span class="s2">"Started running..."</span>
    work
    log_info <span class="s2">"Ended"</span>
<span class="o">}</span>

main <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
</code></pre></div></div>

<p>Save the above to <code class="language-plaintext highlighter-rouge">/tmp/scripter</code> and run it with <code class="language-plaintext highlighter-rouge">bash /tmp/scripter</code> and you’ll have the following output:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[scripter:INFO] main@73: Started running...
[scripter:WARN] work@67: Started working...
[scripter:FATAL] work@68: A fatal error occured!
[scripter:INFO] main@75: Ended
</code></pre></div></div>

<p>As you can see we have both the script name, log level, function name, line number, and the original log.</p>

<p>Let me break it down and tell you how it works, let’s focus first on the essential inner logging function:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>log_inner<span class="o">()</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">log_enabled</span><span class="p">[</span><span class="nv">$1</span><span class="p">]</span><span class="k">}</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"[</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">##*/</span><span class="k">}</span><span class="s2">:</span><span class="k">${</span><span class="nv">1</span><span class="p">^^</span><span class="k">}</span><span class="s2">] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="p">[2]</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">BASH_LINENO</span><span class="p">[1]</span><span class="k">}</span><span class="s2">: </span><span class="k">${</span><span class="p">*</span>:2<span class="k">}</span><span class="s2">"</span>
    <span class="k">fi</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The function’s if-condition does a simple thing: check whether the log level from arg1 (<code class="language-plaintext highlighter-rouge">$1</code>) was enabled, and only print everything from arg2 (<code class="language-plaintext highlighter-rouge">$2</code>) onwards with a log prefix when it is enabled.</p>

<p>In the printing line, the used variables and their definitions are:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">$BASH_SOURCE</code> is a Bash built-in variable, storing the path of the Bash file that was interpreted, here it would be <code class="language-plaintext highlighter-rouge">/tmp/scripter</code>, and as <code class="language-plaintext highlighter-rouge">##*/</code> removes everything before the last <code class="language-plaintext highlighter-rouge">/</code> (inclusive), <code class="language-plaintext highlighter-rouge">${BASH_SOURCE$$*/}</code> would be <code class="language-plaintext highlighter-rouge">scripter</code>;</li>
  <li><code class="language-plaintext highlighter-rouge">$1</code> is the log level, <code class="language-plaintext highlighter-rouge">${1^^}</code> converts the log level to upper case, so if <code class="language-plaintext highlighter-rouge">$1</code> is <code class="language-plaintext highlighter-rouge">info</code> then <code class="language-plaintext highlighter-rouge">${1^^}</code> is <code class="language-plaintext highlighter-rouge">INFO</code>;</li>
  <li><code class="language-plaintext highlighter-rouge">$FUNCNAME</code> is a Bash built-in array containing all of the function names in the stack, backwards in the order they were called, so the full array here would be <code class="language-plaintext highlighter-rouge">FUNCNAME=(log_inner log_[level] main)</code>, the one we want is thus <code class="language-plaintext highlighter-rouge">${FUNCNAME[2]}</code>, the actual function that calls our logging wrappers;</li>
  <li><code class="language-plaintext highlighter-rouge">$BASH_LINENO</code> is a Bash built-in array containing all of the places where the functions are called in the stack, backwards in the order they were called, so the full array here would be <code class="language-plaintext highlighter-rouge">BASH_LINENO=([line No. where log_inner was called] [line No. where log_[level] was called])</code>, the one we want is thus <code class="language-plaintext highlighter-rouge">${BASH_LINENO[1]}</code>, the line number where our logging wrapper was called.</li>
  <li><code class="language-plaintext highlighter-rouge">$*</code> is a Bash magic variable that contains all arguments to the current context (here <code class="language-plaintext highlighter-rouge">log_innner</code>) joined by the first character in <code class="language-plaintext highlighter-rouge">$IFS</code> (here space) to a single string, we want <code class="language-plaintext highlighter-rouge">${*:2}</code>, which only contains the second to last argument.</li>
</ul>

<p>A log wrapper then is defined as follows:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>log_info<span class="o">()</span> <span class="o">{</span>
    log_inner info <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>
</code></pre></div></div>

<p>It just passes its built-in log level and all remaining arguments to the inner printing function. The reason I use dedicated <code class="language-plaintext highlighter-rouge">log_info</code>, <code class="language-plaintext highlighter-rouge">log_warn</code>, etc instead of using the inner <code class="language-plaintext highlighter-rouge">log_inner</code> directly is that I want the logging behaviour to be strictly explicit (remember that we have <code class="language-plaintext highlighter-rouge">set -u</code> (using undefined variable would result in error) and <code class="language-plaintext highlighter-rouge">set -e</code> (error would result in Bash exiting), so caller can only call <code class="language-plaintext highlighter-rouge">log_info</code>, this avoids the error that one might send the wrong logging level)</p>

<p>When one wants to log something they can use one of the logging wrapper in their function:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>work<span class="o">()</span> <span class="o">{</span>
    ...
    log_fatal <span class="s2">"A fatal error occured!"</span>
    ...
<span class="o">}</span>
</code></pre></div></div>
<p>This prints the following log, which is very helpful when debugging:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[scripter:FATAL] work@68: A fatal error occured!
</code></pre></div></div>

<p>Note also we have a helper struct to record whether a log level was enabled, instead of figuring it out everything logging was triggered:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">declare</span> <span class="nt">-gA</span> <span class="nv">log_enabled</span><span class="o">=(</span>
    <span class="o">[</span>debug]<span class="o">=</span><span class="s1">'y'</span>
    <span class="o">[</span>info]<span class="o">=</span><span class="s1">'y'</span>
    <span class="o">[</span>warn]<span class="o">=</span><span class="s1">'y'</span>
    <span class="o">[</span>error]<span class="o">=</span><span class="s1">'y'</span>
    <span class="o">[</span>fatal]<span class="o">=</span><span class="s1">'y'</span>
<span class="o">)</span>
</code></pre></div></div>

<p>To disable a logging level just set the corresponding value to empty:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">local </span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">:-</span><span class="nv">info</span><span class="k">}</span><span class="s2">"</span>
<span class="k">case</span> <span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="p">,,</span><span class="k">}</span><span class="s2">"</span> <span class="k">in</span>
...
<span class="s1">'error'</span><span class="p">)</span>
    log_enabled[debug]<span class="o">=</span><span class="s1">''</span>
    log_enabled[info]<span class="o">=</span><span class="s1">''</span>
    log_enabled[warn]<span class="o">=</span><span class="s1">''</span>
    <span class="p">;;</span>
...
<span class="k">esac</span>
</code></pre></div></div>

<p>This saves the work that is needed when figuring out whether a log level shall be enabled: getting the value from an assotiated Bash array is much faster than doing calculation every time from environment</p>

<p>Like the following:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">log_enabled</span><span class="p">[info]</span><span class="k">}</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span>do_complex_logging_when_info_level_is_enabled
<span class="k">fi</span>
</code></pre></div></div>
<p>is of course lighter than the following:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">}</span><span class="s2">"</span> <span class="k">in
</span>info|warn|error|fatal<span class="p">)</span>
    do_complex_logging_when_info_level_is_enabled
    <span class="p">;;</span>
<span class="k">esac</span>
</code></pre></div></div>

<p>If there’s no need to figure out the log level again during runtime, then the demo can be simplified to the following:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>log_inner<span class="o">()</span> <span class="o">{</span>
    <span class="nb">echo</span> <span class="s2">"[</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">##*/</span><span class="k">}</span><span class="s2">:</span><span class="nv">$1</span><span class="s2">] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="p">[2]</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">BASH_LINENO</span><span class="p">[1]</span><span class="k">}</span><span class="s2">: </span><span class="k">${</span><span class="p">*</span>:2<span class="k">}</span><span class="s2">"</span>
<span class="o">}</span>

log_debug<span class="o">()</span> <span class="o">{</span>
    log_inner DEBUG <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_info<span class="o">()</span> <span class="o">{</span>
    log_inner INFO <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_warn<span class="o">()</span> <span class="o">{</span>
    log_inner WARN <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_error<span class="o">()</span> <span class="o">{</span>
    log_inner ERROR <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

log_fatal<span class="o">()</span> <span class="o">{</span>
    log_inner FATAL <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
<span class="o">}</span>

initialize<span class="o">()</span> <span class="o">{</span>
    <span class="nb">set</span> <span class="nt">-euo</span> pipefail
    <span class="nb">local </span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">:-</span><span class="nv">info</span><span class="k">}</span><span class="s2">"</span>
    <span class="k">case</span> <span class="s2">"</span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="p">,,</span><span class="k">}</span><span class="s2">"</span> <span class="k">in</span>
    <span class="s1">'debug'</span><span class="p">)</span>
        :
        <span class="p">;;</span>
    <span class="s1">'info'</span><span class="p">)</span>
        log_debug<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        <span class="p">;;</span>
    <span class="s1">'warn'</span><span class="p">)</span>
        log_debug<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_info<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        <span class="p">;;</span>
    <span class="s1">'error'</span><span class="p">)</span>
        log_debug<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_info<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_warn<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        <span class="p">;;</span>
    <span class="s1">'fatal'</span><span class="p">)</span>
        log_debug<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_info<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_warn<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        log_error<span class="o">()</span> <span class="o">{</span> :<span class="p">;</span> <span class="o">}</span>
        <span class="p">;;</span>
    <span class="k">*</span><span class="p">)</span>
        log_fatal <span class="s2">"Unknown log level </span><span class="k">${</span><span class="nv">AIMAGER_LOG_LEVEL</span><span class="k">}</span><span class="s2">, shall be one of the"</span><span class="se">\</span>
            <span class="s2">"following (case-insensitive): debug, info, warn, error, fatal"</span>
        <span class="k">return </span>1
        <span class="p">;;</span>
    <span class="k">esac</span>
<span class="o">}</span>

work<span class="o">()</span> <span class="o">{</span>
    log_warn <span class="s2">"Started working..."</span>
    log_fatal <span class="s2">"A fatal error occured!"</span>
<span class="o">}</span>

main<span class="o">()</span> <span class="o">{</span>
    initialize
    log_info <span class="s2">"Started running..."</span>
    work
    log_info <span class="s2">"Ended"</span>
<span class="o">}</span>

main <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="scripting" /><summary type="html"><![CDATA[Three months ago I’ve written a blog post to document how to print logs in Bash with function names and line numbers just like in C, however the method documented there relied on eval and is not very clean. A few weeks ago I found a better way to do this and I just had time to write it down.]]></summary></entry><entry><title type="html">Bash logging with Function name and Line No.</title><link href="https://7ji.github.io/scripting/2024/09/29/bash-logging-with-funcname-lineno.html" rel="alternate" type="text/html" title="Bash logging with Function name and Line No." /><published>2024-09-29T09:00:00+00:00</published><updated>2024-09-29T09:00:00+00:00</updated><id>https://7ji.github.io/scripting/2024/09/29/bash-logging-with-funcname-lineno</id><content type="html" xml:base="https://7ji.github.io/scripting/2024/09/29/bash-logging-with-funcname-lineno.html"><![CDATA[<p><strong>Updated on 2024-12-27: I’ve written an improved way of logging without the need of <code class="language-plaintext highlighter-rouge">eval</code> calls and it’s recommend to read the <a href="../../12/27/bash-logging-improved.html">new blog post</a></strong></p>

<p>When writing some lengthy bash script, one might want the script to log with function name and line number so it would be easy to trace some error-prone logics in the future, like how you would use <code class="language-plaintext highlighter-rouge">__FUNCTION__</code> and <code class="language-plaintext highlighter-rouge">__LINE__</code> macros in C projects compiled with GCC.</p>

<p>Luckily there’re <code class="language-plaintext highlighter-rouge">$FUNCNAME</code> and <code class="language-plaintext highlighter-rouge">$LINENO</code> built-in variables. So you could write your logging statement like this:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>myfunc<span class="o">()</span> <span class="o">{</span>
    <span class="nb">echo</span> <span class="s2">"[DEBUG] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">LINENO</span><span class="k">}</span><span class="s2">: Starting some work..."</span>
    <span class="k">if </span>work<span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"[INFO] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">LINENO</span><span class="k">}</span><span class="s2">: Successfully finished the work..."</span>
    <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"[ERROR] </span><span class="k">${</span><span class="nv">FUNCNAME</span><span class="k">}</span><span class="s2">@</span><span class="k">${</span><span class="nv">LINENO</span><span class="k">}</span><span class="s2">: Failed to do the work !"</span>
        <span class="k">return </span>1
    <span class="k">fi</span>
<span class="o">}</span>
</code></pre></div></div>

<p>However writing these lengthy prefixes is both annoying and error-prone, and it reduces the information density which is unhelpful when you go back to improve the codes.</p>

<p>It’s of course not possible to replace these with functions, as <code class="language-plaintext highlighter-rouge">$FUNCNAME</code> and <code class="language-plaintext highlighter-rouge">$LINENO</code> would then not trace the place where functions are actually called, but only their inner state. And it would be even more tedious if you want to conditionally log depending on the log level.</p>

<p>To simplify the latter typing work while keeping <code class="language-plaintext highlighter-rouge">$FUNCNAME</code> and <code class="language-plaintext highlighter-rouge">$LINENO</code> as where they’re called, and have some conditonal log levels, you can define some “macros” that would be expanded by eval:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">log_common_start</span><span class="o">=</span><span class="s1">'echo -n "['</span>
<span class="nv">log_common_end</span><span class="o">=</span><span class="s1">'] ${FUNCNAME}@${LINENO}: " &amp;&amp; false'</span>
<span class="nv">log_info</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">log_common_start</span><span class="k">}</span><span class="s2">INFO</span><span class="k">${</span><span class="nv">log_common_end</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">log_warn</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">log_common_start</span><span class="k">}</span><span class="s2">WARN</span><span class="k">${</span><span class="nv">log_common_end</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">log_error</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">log_common_start</span><span class="k">}</span><span class="s2">ERROR</span><span class="k">${</span><span class="nv">log_common_end</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">log_fatal</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">log_common_start</span><span class="k">}</span><span class="s2">FATAL</span><span class="k">${</span><span class="nv">log_common_end</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Debugging-only definitions</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">aimager_debug</span><span class="k">}</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
</span><span class="nv">log_debug</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">log_common_start</span><span class="k">}</span><span class="s2">DEBUG</span><span class="k">${</span><span class="nv">log_common_end</span><span class="k">}</span><span class="s2">"</span>
<span class="k">else
</span><span class="nv">log_debug</span><span class="o">=</span><span class="s1">'true'</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>With the above definition you can write that function instead like this:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>myfunc<span class="o">()</span> <span class="o">{</span>
    <span class="nb">eval</span> <span class="s2">"</span><span class="nv">$log_debug</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'Starting some work...'</span>
    <span class="k">if </span>work<span class="p">;</span> <span class="k">then
        </span><span class="nb">eval</span> <span class="s2">"</span><span class="nv">$log_info</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'Successfully finished the work...'</span>
    <span class="k">else
        </span><span class="nb">eval</span> <span class="s2">"</span><span class="nv">$log_error</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'Failed to do the work !'</span>
        <span class="k">return </span>1
    <span class="k">fi</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The way this works is that, for logging levels enabled, the logging lines are basically expanded to:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"prefix"</span> <span class="o">&amp;&amp;</span> <span class="nb">false</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'content'</span>
</code></pre></div></div>
<p>and both <code class="language-plaintext highlighter-rouge">echo</code>es would be executed in this case;</p>

<p>and for logging levels disabled, the logging lines are basically expanded to:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">true</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'content'</span>
</code></pre></div></div>
<p>and no <code class="language-plaintext highlighter-rouge">echo</code> would be executed in this acse</p>

<p>As a bonus point, you can execute some logics dynamically depending on the log level, e.g.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span> <span class="nb">eval</span> <span class="s2">"</span><span class="nv">$log_info</span><span class="s2">"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s1">'Running specific logic when logging level INFO is enabled'</span>
    some_logic_when_info_is_enabled
<span class="k">else
    </span>other_logic_when_info_is_disabled
<span class="k">fi</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="scripting" /><summary type="html"><![CDATA[Updated on 2024-12-27: I’ve written an improved way of logging without the need of eval calls and it’s recommend to read the new blog post]]></summary></entry></feed>