<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://b3.hk/feed.xml" rel="self" type="application/atom+xml" /><link href="https://b3.hk/" rel="alternate" type="text/html" /><updated>2026-10-17T21:02:19+00:00</updated><id>https://b3.hk/feed.xml</id><title type="html">Wenri JUSHI</title><subtitle>IS NULL and IS NOT NULL</subtitle><author><name>Wenri JUSHI</name></author><entry><title type="html">The Infrastructure I Run</title><link href="https://b3.hk/the-infrastructure-i-run/" rel="alternate" type="text/html" title="The Infrastructure I Run" /><published>2026-07-30T00:05:00+00:00</published><updated>2026-07-30T00:05:00+00:00</updated><id>https://b3.hk/the-infrastructure-i-run</id><content type="html" xml:base="https://b3.hk/the-infrastructure-i-run/"><![CDATA[<p>Everything I run — servers, workstations, a laptop, a WSL install, and the Android phone in my pocket — is described by a single Nix flake. Twenty-one hosts, one <code class="language-plaintext highlighter-rouge">hosts/default.nix</code> listing them, one <code class="language-plaintext highlighter-rouge">common/</code> directory holding everything they share. <code class="language-plaintext highlighter-rouge">make switch HOST=&lt;name&gt;</code> and the machine converges on what the repo says it should be.</p>

<p>This post is the overview: what’s running and how the fleet is shaped. The parts that need real room to explain have their own posts, indexed at the bottom.</p>

<h2 id="services">Services</h2>

<p>Before the internals, the services the fleet runs.</p>

<p>Open to anyone:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Function</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Blog</td>
      <td>Jekyll static site, served behind Traefik on the Matrix VPS.</td>
    </tr>
    <tr>
      <td>Matrix homeserver</td>
      <td>Federated messaging with Synapse behind Traefik.</td>
    </tr>
    <tr>
      <td>Element</td>
      <td>The web client for the Matrix homeserver.</td>
    </tr>
    <tr>
      <td>Bluesky PDS</td>
      <td>A self-hosted personal data server.</td>
    </tr>
    <tr>
      <td>Signed apt repository</td>
      <td>Vendor <code class="language-plaintext highlighter-rouge">.deb</code>s that ship no repo of their own — metadata only, pointing at upstream download URLs.</td>
    </tr>
  </tbody>
</table>

<p>Sign-in required:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Function</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Coder</td>
      <td>The control plane for my dev workspaces; the machines that actually run them are elsewhere in the fleet.</td>
    </tr>
    <tr>
      <td>Netmaker</td>
      <td>The dashboard for the mesh VPN that connects the fleet.</td>
    </tr>
    <tr>
      <td>Portainer</td>
      <td>Management for the container stack on the host below.</td>
    </tr>
    <tr>
      <td>Karakeep</td>
      <td>Bookmarks and read-later, self-hosted.</td>
    </tr>
    <tr>
      <td>Grafana</td>
      <td>Monitoring the fleet.</td>
    </tr>
  </tbody>
</table>

<p>Nearly all of that shares a single small container, which is a slightly absurd amount of load for an OpenVZ guest on a 3.10 kernel and works fine.</p>

<p>What’s <em>not</em> in either table is the shell access: SSH to every host in the fleet reaches the internet only through Cloudflare tunnels, so there is no listening port to publish in the first place. That asymmetry is deliberate — the interesting engineering in this setup is in tunnels, meshes and grafting rather than virtual hosts.</p>

<h2 id="the-fleet">The fleet</h2>

<p>By type, as the flake partitions them:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Count</th>
      <th>What they are</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">server</code></td>
      <td>12</td>
      <td>Cloud VMs and a couple of bare-metal boxes, spread across Asia, Europe and the US</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">non-nixos</code></td>
      <td>6</td>
      <td>Stock Ubuntu machines, plus one OpenVZ container, managed by home-manager alone</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">desktop</code></td>
      <td>1</td>
      <td>A <a href="/a-2009-imac-that-refuses-to-die/">2009 iMac</a> (yes, really)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wsl</code></td>
      <td>1</td>
      <td>NixOS-WSL for Windows work</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">android</code></td>
      <td>1</td>
      <td>This phone</td>
    </tr>
  </tbody>
</table>

<p>Only fourteen of them run NixOS. That turns out to be the interesting thing about the setup: the engineering effort isn’t in the NixOS hosts, it’s in everything else. A NixOS module and a home-manager module that produce the same behaviour on stock Ubuntu need to agree, so the shared logic sits in pure-data modules underneath both — one source of truth, two renderers. When the Ubuntu box needs a firewall rule that its NixOS sibling gets from <code class="language-plaintext highlighter-rouge">networking.firewall</code>, the same data goes through a generator that writes a nushell script into <code class="language-plaintext highlighter-rouge">/etc/ufw/after.init</code> instead.</p>

<p>On those six non-NixOS hosts there’s one decision worth stealing. <code class="language-plaintext highlighter-rouge">/nix/var/nix/profiles/default</code> points at root’s home-manager profile, so <code class="language-plaintext highlighter-rouge">pkgs.nix</code> in root’s <code class="language-plaintext highlighter-rouge">home.packages</code> <em>is</em> the machine’s Nix, and its version is a property of <code class="language-plaintext highlighter-rouge">flake.lock</code>. The <code class="language-plaintext highlighter-rouge">nix-daemon</code> units are rendered by home-manager with a profile-indirect <code class="language-plaintext highlighter-rouge">ExecStart</code>. Upgrading Nix on a machine that doesn’t run NixOS becomes <code class="language-plaintext highlighter-rouge">make home-root HOST=&lt;h&gt;</code> and a daemon restart.</p>

<h2 id="deep-dives">Deep dives</h2>

<p>Three parts of this needed more room than an overview allows:</p>

<ul>
  <li><strong><a href="/nix-on-a-phone/">Nix on a Phone, and the Trick That Makes It Work</a></strong> — running a full Nix userland on Android, and the base-32-to-base-64 hash re-encoding that keeps store paths byte-identical in length while pointing somewhere else entirely.</li>
  <li><strong><a href="/a-2009-imac-that-refuses-to-die/">A 2009 iMac That Refuses to Die</a></strong> — inverted SSH ciphers on a CPU with no AES-NI, a measured 92 MiB kernel diet, an ext4 patch with an upstream submission kit, and swap over NVMe-over-TCP.</li>
  <li><strong><a href="/pins-secrets-and-deploys/">Pins, Secrets, and Deploys That Can’t Strand You</a></strong> — seven nixpkgs branches each pinned for a stated reason, secrets without agenix, and two mechanisms that stop a remote switch from bricking the box.</li>
</ul>

<p>Still to write: the cross-border connectivity layer — mesh VPN, reverse tunnels, multipath TCP — which is the other half of the repo.</p>

<h2 id="what-the-repo-is-actually-for">What the repo is actually for</h2>

<p>The thing I’d most want to convey isn’t any single mechanism. It’s a habit: in this repo, comments don’t say what the code does. They say what broke.</p>

<blockquote>
  <p>[host] demonstrated exactly this (“Failed to open …/nix-daemon.socket” three seconds before nix.mount).</p>
</blockquote>

<blockquote>
  <p>1690/1693 bin-farm symlinks are 60–90 B targets.</p>
</blockquote>

<blockquote>
  <p>measured: 0 <code class="language-plaintext highlighter-rouge">kvm:kvm_userspace_exit</code> in 10 s.</p>
</blockquote>

<blockquote>
  <p>the gate boot missed by EXACTLY TWO MESSAGES.</p>
</blockquote>

<p>Every one of those is a debugging session someone (me) already paid for, written down at the exact line where the next person would otherwise repeat it. Nix is what makes the fleet reproducible. The comments are what make it <em>maintainable</em> — they’re the difference between a configuration I can still change in two years and one I’d be afraid to touch.</p>

<p>That, more than the twenty-one hosts, is the infrastructure.</p>]]></content><author><name>Wenri JUSHI</name></author><summary type="html"><![CDATA[One Nix flake, twenty-one hosts, five different operating-system situations — including a phone and a 2009 iMac. An overview, and an index of the deep dives.]]></summary></entry><entry><title type="html">Pins, Secrets, and Deploys That Can’t Strand You</title><link href="https://b3.hk/pins-secrets-and-deploys/" rel="alternate" type="text/html" title="Pins, Secrets, and Deploys That Can’t Strand You" /><published>2026-07-30T00:04:00+00:00</published><updated>2026-07-30T00:04:00+00:00</updated><id>https://b3.hk/pins-secrets-and-deploys</id><content type="html" xml:base="https://b3.hk/pins-secrets-and-deploys/"><![CDATA[<p>This is part of a series about the single Nix flake that describes every machine I run — see <a href="/the-infrastructure-i-run/">The Infrastructure I Run</a> for the overview and the host inventory.</p>

<p>This post is about the unglamorous half: how versions get pinned, how secrets move, and how a deploy avoids leaving a machine unreachable. None of it is clever in isolation. All of it is what makes the rest safe to touch.</p>

<h2 id="seven-nixpkgs-each-for-a-reason">Seven nixpkgs, each for a reason</h2>

<p>The flake tracks seven nixpkgs branches simultaneously. That sounds undisciplined until you read why each one is pinned — every input in <code class="language-plaintext highlighter-rouge">flake.nix</code> carries its justification inline:</p>

<ul>
  <li><strong>26.05</strong> — the default everywhere, Hydra-verified.</li>
  <li><strong>unstable</strong> — opt-in per package via <code class="language-plaintext highlighter-rouge">pkgs.unstable.*</code>.</li>
  <li><strong>25.11</strong> — glibc 2.40, because that’s what <a href="/nix-on-a-phone/">the phone’s</a> runtime is built against.</li>
  <li><strong>24.11</strong> — mesa 24.2, the last release with bundled <code class="language-plaintext highlighter-rouge">libgbm</code>.</li>
  <li><strong>24.05</strong> — systemd 255, the last version whose <code class="language-plaintext highlighter-rouge">chase_and_open</code> still has a <code class="language-plaintext highlighter-rouge">name_to_handle_at</code> fallback. One host is an OpenVZ container on a 3.10 kernel that lacks the syscall.</li>
  <li><strong>22.11</strong> — xorg-server 1.20, the newest X the NVIDIA 340.xx legacy driver can talk to.</li>
  <li><strong>22.05</strong> — gcc-12 and a glibc 2.34 floor, for building release binaries that assume an older toolchain.</li>
</ul>

<p>And one more that isn’t a branch at all: a specific revision hash from <code class="language-plaintext highlighter-rouge">release-20.09</code>, frozen in 2022, kept alive because it’s the last nixpkgs containing a <code class="language-plaintext highlighter-rouge">cudatoolkit_6_5</code> expression. 21.05 dropped CUDA older than 10. I have <a href="/a-2009-imac-that-refuses-to-die/">a GPU that needs 6.5</a>.</p>

<p>None of these leak into each other. The CUDA one is the sharpest case: the override swaps a modern stdenv, modern dependencies and a rewrapped older gcc into the 2020-era recipe, so it evaluates against ancient expressions but <em>no 20.09 store path reaches the runtime closure</em>.</p>

<p>The pattern worth stealing here isn’t the number of pins — it’s that each one names the specific artifact it exists for. A pin justified as “newer versions broke something” rots into a mystery within a year. A pin justified as “mesa 24.2, last release with bundled libgbm” tells you exactly what has to become true before you can delete it.</p>

<h2 id="secrets">Secrets</h2>

<p>No agenix, no sops-nix. A private Git submodule is consumed as a flake input, which means it’s pinned in <code class="language-plaintext highlighter-rouge">flake.lock</code> like anything else, and its contents arrive at modules as an ordinary function argument through <code class="language-plaintext highlighter-rouge">specialArgs</code>.</p>

<p>Two rules make it hold up. Secrets are passed to services <strong>as files</strong> — rendered with <code class="language-plaintext highlighter-rouge">writeText</code>, or staged out of band by the installer — never as literals on a command line where they’d show up in <code class="language-plaintext highlighter-rouge">ps</code>. And where a secret is owned by something outside Nix, it’s resolved at <em>merge</em> time from that system’s own env file rather than being copied into the repo, so it never enters the Nix store at all; a job whose key is missing is skipped with a warning rather than silently mis-deploying. Public keys, meanwhile, live in the open in a plain tracked file, because they’re public.</p>

<p>The honest caveat: anything rendered into a unit file or a config is readable by whoever can read that file, and this scheme doesn’t pretend otherwise. Where that exposure exists, the module says so in a comment rather than implying a guarantee it can’t make. I’d rather have a documented boundary than an encrypted-at-rest story that quietly decrypts into a world-readable store path anyway.</p>

<h2 id="deploying-without-losing-the-machine">Deploying without losing the machine</h2>

<p>Two safety mechanisms I’d recommend to anyone administering remote machines.</p>

<p>First, every <code class="language-plaintext highlighter-rouge">switch</code> runs inside a transient system-level systemd unit, so an SSH disconnect can’t kill a half-applied configuration change. The invocation looks over-engineered and each layer was earned:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemd-run --pty --pipe --wait --collect --same-dir \
  env --ignore-signal=SIGHUP --ignore-signal=SIGINT \
  script -qefc "&lt;cmd&gt;" /tmp/&lt;unit&gt;.log
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">systemd-run</code> moves the work off the SSH connection’s lifetime. The <code class="language-plaintext highlighter-rouge">script(1)</code> wrapper is there because when the client dies, the outer pty hangs up and <em>every later write fails with EIO even with SIGHUP ignored</em> — a bare <code class="language-plaintext highlighter-rouge">--pty</code> command dies mid-switch. <code class="language-plaintext highlighter-rouge">script</code> absorbs the EIO while the command’s inner pty stays healthy, which keeps live colour output and progress bars working. (<code class="language-plaintext highlighter-rouge">nohup</code> can’t substitute: with a tty stdout it redirects to <code class="language-plaintext highlighter-rouge">nohup.out</code> and you lose the live view.)</p>

<p>Second, reinstalling a box whose only access path is a reverse SSH tunnel. A stock kexec image would sever that tunnel and strand the machine. So the installer image <strong>inherits the target’s own SSH host key and uses it as a client identity</strong> to dial back out to a relay, re-exposing its sshd there. Two properties fall out of that: the image contains no secrets, so it can be cached and shared freely; and until <code class="language-plaintext highlighter-rouge">disko</code> actually partitions anything, kexec is RAM-only — a power cycle falls back to the untouched old system, which restores the original tunnel. A failed phone-home is recoverable without console access.</p>

<p>That second property is the one I’d underline. The interesting question about a remote reinstall isn’t whether the happy path works; it’s what state the machine is in when the process dies halfway. Making the destructive step the <em>last</em> step, and keeping everything before it in RAM, converts “drive four hours to attach a console” into “cut the power and try again.”</p>]]></content><author><name>Wenri JUSHI</name></author><summary type="html"><![CDATA[Seven nixpkgs branches, each pinned for a stated reason; secrets without agenix or sops; and two mechanisms that stop a remote switch from bricking the machine.]]></summary></entry><entry><title type="html">Nix on a Phone, and the Trick That Makes It Work</title><link href="https://b3.hk/nix-on-a-phone/" rel="alternate" type="text/html" title="Nix on a Phone, and the Trick That Makes It Work" /><published>2026-07-30T00:03:00+00:00</published><updated>2026-07-30T00:03:00+00:00</updated><id>https://b3.hk/nix-on-a-phone</id><content type="html" xml:base="https://b3.hk/nix-on-a-phone/"><![CDATA[<p>This is part of a series about the single Nix flake that describes every machine I run — see <a href="/the-infrastructure-i-run/">The Infrastructure I Run</a> for the overview. One of the hosts in that flake is an Android phone, and it’s the one that needed genuinely new machinery.</p>

<p>The phone runs a full Nix userland on Android, against a custom glibc 2.40 carrying Termux’s kernel-compatibility patches, with a patched fakechroot as <code class="language-plaintext highlighter-rouge">/etc/ld.so.preload</code>. It has the same shell, the same editors, the same package set as every other host. I wrote this post on it.</p>

<h2 id="the-constraint">The constraint</h2>

<p>Getting there required reimplementing nixpkgs’ <code class="language-plaintext highlighter-rouge">replaceDependencies</code>, and the reason is a nice constraint.</p>

<p>Upstream patches a dependency out of an already-built closure by <em>binary string substitution</em> — it walks the NAR stream and rewrites store paths in place. Rewriting bytes inside a compiled ELF file means you cannot change how many bytes there are: shifting everything after the edit would break every offset in the file. So the old and new paths must be <strong>byte-identical in length</strong>.</p>

<p>That constraint fails twice on Android. Standard glibc and an Android-patched glibc don’t have the same path length. And <code class="language-plaintext highlighter-rouge">/nix/store</code> doesn’t exist at the real path on Android at all — the store lives under the app’s data directory, which is a different length again.</p>

<h2 id="the-trick">The trick</h2>

<p>The solution is a three-layer rewrite, and the middle layer is the clever part. Every store path maps to a symlink whose basename is a <em>re-encoding of the same Nix hash</em>:</p>

<blockquote>
  <p>The Nix store path already carries a 20-byte hash encoded in nix-base-32 as the first 32 chars of the basename. We re-encode it as base-64 URL-safe (27 chars for the same 160 bits — nix-base-32 is 5 bits/char, base-64 is 6) and take a prefix that makes the symlink path same total length as the original store path.</p>
</blockquote>

<p>Base-32 spends five bits per character; base-64 spends six. The same 160-bit hash therefore fits in 27 characters instead of 32, and those five recovered characters are the budget you spend on a different path prefix — while keeping the total length identical, which is what the byte-level substitution demands.</p>

<p>It’s a pleasing kind of solution: the information you need to preserve (160 bits of hash) and the constraint you have to satisfy (exact path length) turn out to be independent, once you stop assuming the hash has to stay in the encoding Nix chose for it. For a typical 44-character basename the crop lands at exactly 27 chars, so the full hash survives. At the pathological minimum it’s 16 chars — still 96 bits, still collision-safe.</p>

<h2 id="the-three-layers">The three layers</h2>

<p>Layer one does OLD → SYMLINK, same-length by construction, as above.</p>

<p>Layer two does SYMLINK → REAL structurally rather than byte-wise: ELF interpreters, RPATHs, NAR symlink targets, script literals, shebangs. These are all length-agnostic — you’re rewriting a field, not patching bytes in place — so the real path can be any length it likes. The glibc swap itself, being cross-length, rides this pass.</p>

<p>The result is that the whole <em>environment</em> gets patched at once. There are no per-package Android variants to maintain: packages come out of the ordinary binary cache and get rewritten on the way in, rather than being rebuilt from source for the platform. On a phone, that distinction is the difference between a working system and a battery-shaped space heater.</p>

<p>Everything above is why the Makefile in the blog you’re reading can invoke a grafted Ruby 4.0 on a phone and have it behave like Ruby on a normal Linux box — the same <code class="language-plaintext highlighter-rouge">bundle exec jekyll build</code> as the CI runner, on the same flake, from a device in my pocket.</p>]]></content><author><name>Wenri JUSHI</name></author><summary type="html"><![CDATA[A full Nix userland on Android needs a reimplemented replaceDependencies — and a base-32-to-base-64 hash re-encoding to keep store paths byte-identical in length.]]></summary></entry><entry><title type="html">A 2009 iMac That Refuses to Die</title><link href="https://b3.hk/a-2009-imac-that-refuses-to-die/" rel="alternate" type="text/html" title="A 2009 iMac That Refuses to Die" /><published>2026-07-30T00:02:00+00:00</published><updated>2026-07-30T00:02:00+00:00</updated><id>https://b3.hk/a-2009-imac-that-refuses-to-die</id><content type="html" xml:base="https://b3.hk/a-2009-imac-that-refuses-to-die/"><![CDATA[<p>This is part of a series about the single Nix flake that describes every machine I run — see <a href="/the-infrastructure-i-run/">The Infrastructure I Run</a> for the overview.</p>

<p>Exactly one host in that flake is a desktop, and it’s a 2009 iMac: Core 2 Duo, 3.8 GiB of RAM, a GeForce 9400M. Its notes file is the longest in the repo, and it has an <code class="language-plaintext highlighter-rouge">evidence/</code> directory of GPU-freeze forensics. It’s also the reason the flake carries <a href="/pins-secrets-and-deploys/">a nixpkgs revision frozen in 2022</a> — the last one that still packages CUDA 6.5.</p>

<p>Keeping a sixteen-year-old machine genuinely useful, rather than nominally booting, turns out to be a series of specific fights. Here are four.</p>

<h2 id="inverted-cipher-preferences">Inverted cipher preferences</h2>

<p>The Penryn CPU has no AES-NI and no PCLMULQDQ, so AES is software and GCM’s GHASH is table-driven. Measured on the box: ChaCha20-Poly1305 costs 4.7 CPU-seconds per GB, AES-256-GCM costs 8.3.</p>

<p>This host therefore wants the <em>opposite</em> SSH cipher order from the rest of the fleet, whose cloud CPUs all have AES-NI and for which the ordering is reversed. So the setting is host-scoped, with a comment forbidding anyone from hoisting it into the shared module — because the shared module is exactly where a well-meaning future me would put it.</p>

<p>This is the general shape of the problem with old hardware in a fleet: the correct configuration isn’t a worse version of the modern one, it’s a different one, and the abstraction that serves twenty homogeneous machines actively fights the twenty-first.</p>

<h2 id="a-92-mib-kernel-memory-diet">A ~92 MiB kernel memory diet</h2>

<p>On a machine with 3.8 GiB, kernel overhead is real. The diet was itemised and measured rather than guessed: printk shift, BTF off, the ftrace family off, the kexec family off, a smaller <code class="language-plaintext highlighter-rouge">swiotlb</code>.</p>

<p>Verified across a reboot — reserved memory went from 275,960K to 204,260K. Every item in that list was checked live before it was changed, which matters, because roughly half of the “obvious” savings I expected turned out to be worth nothing on this kernel.</p>

<h2 id="an-ext4-patch-with-an-upstream-submission-kit">An ext4 patch with an upstream submission kit</h2>

<p>Kernel-side creation of inline-data symlinks was removed upstream. On this machine that’s a measurable cost, because a survey of the Nix bin farm found <strong>1690 of 1693 symlinks have 60–90 byte targets</strong> — small enough to live in the inode, but instead each one gets a 4 KiB block and a cold seek on a spinning disk.</p>

<p>The patch re-adds it. Two details made it more than a local hack: the on-disk format matches <code class="language-plaintext highlighter-rouge">e2fsck</code> 1.47.4’s validation <em>exactly</em> — a mismatch would make pass 2 deallocate the symlinks, and this host boots with <code class="language-plaintext highlighter-rouge">fsck.repair=yes</code>, so getting it subtly wrong would eat the filesystem rather than merely fail — and the tree carries a cover letter, a documentation patch and an fstests case, ready to send.</p>

<h2 id="swap-over-the-network">Swap over the network</h2>

<p>It swaps to a 16 GB NVMe-over-TCP device exported by a workstation elsewhere on the network.</p>

<p>The lease protocol is my favourite thing in the repo. A client hands the daemon a <strong>pidfd over a Unix socket with <code class="language-plaintext highlighter-rouge">SCM_RIGHTS</code></strong>, then <code class="language-plaintext highlighter-rouge">execv</code>s its real payload after closing everything. The daemon polls that pidfd, which becomes readable on any exit — including <code class="language-plaintext highlighter-rouge">SIGKILL</code>, and immune to PID reuse, which a bare PID check is not.</p>

<p>The properties fall out for free rather than being enforced: nothing is inherited into the payload process, so it can’t leak the lease; concurrent users refcount naturally; and the whole thing is crash-safe by construction rather than by cleanup handler. Because it’s socket-activated, no client path needs sudo, polkit or systemctl — access control <em>is</em> the socket’s file mode. It tears itself down after a grace period and exits.</p>

<h2 id="why-bother">Why bother</h2>

<p>The machine owes me nothing. But every one of these fights produced something reusable: the cipher lesson applies to any heterogeneous fleet, the ext4 patch is going upstream, and the swap lease is a pattern I’d use anywhere I needed a crash-safe resource handoff between unprivileged processes.</p>

<p>Old hardware is a good teacher because it refuses to let you paper over inefficiency with headroom.</p>]]></content><author><name>Wenri JUSHI</name></author><summary type="html"><![CDATA[Inverted SSH ciphers, a measured 92 MiB kernel diet, an ext4 patch with an upstream submission kit, and swap over NVMe-over-TCP.]]></summary></entry><entry><title type="html">Welcome to Wenri JUSHI</title><link href="https://b3.hk/welcome/" rel="alternate" type="text/html" title="Welcome to Wenri JUSHI" /><published>2024-12-02T00:00:00+00:00</published><updated>2024-12-02T00:00:00+00:00</updated><id>https://b3.hk/welcome</id><content type="html" xml:base="https://b3.hk/welcome/"><![CDATA[<p>Welcome. I write here as <strong>Wenri JUSHI</strong>, and this blog collects the things I learn while working on systems and software.</p>

<h2 id="what-youll-find-here">What you’ll find here</h2>

<ul>
  <li><strong><a href="/about/">About</a></strong> — the interests behind these notes.</li>
  <li><strong><a href="/">The blog</a></strong> — practical writing about Nix, operating systems, networking, self-hosting, and debugging.</li>
  <li><strong><a href="/search/">Search</a></strong> — find a topic or return to a detail from an earlier post.</li>
</ul>

<h2 id="why-keep-a-notebook">Why keep a notebook?</h2>

<p>The final command rarely tells the whole story. These posts keep the constraints, failed attempts, and reasons behind a solution alongside the steps that worked.</p>

<p>I hope they save someone else a little time, or give them a useful place to start.</p>]]></content><author><name>Wenri JUSHI</name></author><summary type="html"><![CDATA[Notes on systems, self-hosting, and software from Wenri JUSHI.]]></summary></entry></feed>