<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Christian Anderson</title>
    <description>The latest articles on DEV Community by Christian Anderson (@iam-tech).</description>
    <link>https://dev.to/iam-tech</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110779%2Ff7ed549e-875c-4c07-8cb7-c1d3ff6a355e.jpg</url>
      <title>DEV Community: Christian Anderson</title>
      <link>https://dev.to/iam-tech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iam-tech"/>
    <language>en</language>
    <item>
      <title>Set up ZimaBlade backups in 20 minutes, and practise first on any box</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:00:18 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-zimablade-backups-in-20-minutes-and-practise-first-on-any-box-i8k</link>
      <guid>https://dev.to/iam-tech/set-up-zimablade-backups-in-20-minutes-and-practise-first-on-any-box-i8k</guid>
      <description>&lt;p&gt;My &lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;ZimaBlade recipes&lt;/a&gt; are the stuff I learned running a ZimaBlade like a server instead of an appliance. The one most people need is backups, because the obvious way is broken in a way you don't notice.&lt;/p&gt;

&lt;p&gt;On ZimaOS your SSH user isn't root and can't sudo. Parts of &lt;code&gt;/DATA/AppData&lt;/code&gt; are root-only, mode 0700: your reverse proxy's certificates, tunnel tokens, auth databases. A plain &lt;code&gt;tar&lt;/code&gt; can't read them. It complains, exits 2, and &lt;strong&gt;still writes the archive&lt;/strong&gt;. You get a backup of a believable size with exactly the files you can't recreate missing from it.&lt;/p&gt;

&lt;p&gt;The new &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; lets you watch that happen, and the fix, on any Linux machine with Docker before you touch your real data. That practice run is tested on a fresh Ubuntu machine every week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15d268cpprdyj47wdw47.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15d268cpprdyj47wdw47.png" alt="The practice run on a fresh GitHub runner: plain tar fails on the root-only tls folder, the dry run lists it, the backup includes it, and verify proves it" width="799" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Practise on a throwaway folder
&lt;/h2&gt;

&lt;p&gt;The guide builds a fake AppData in &lt;code&gt;/tmp&lt;/code&gt; with one ordinary app and one root-only &lt;code&gt;tls&lt;/code&gt; folder, then points the scripts at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SRC_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/AppData &lt;span class="nv"&gt;DEST_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/Backups &lt;span class="nv"&gt;CONFIG_DIRS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/apps
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; /tmp/plain.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /tmp/zima-practice AppData   &lt;span class="c"&gt;# Permission denied, exit 2, archive written anyway&lt;/span&gt;
bash scripts/backup-appdata.sh &lt;span class="nt"&gt;--dry-run&lt;/span&gt;                     &lt;span class="c"&gt;# lists the folder a plain tar would miss&lt;/span&gt;
bash scripts/backup-appdata.sh                               &lt;span class="c"&gt;# root via a privileged container: it gets in&lt;/span&gt;
bash scripts/verify-backup.sh                                &lt;span class="c"&gt;# proves it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that matters is &lt;code&gt;OK — all 1 root-only director(ies) are in the archive&lt;/code&gt;. An ordinary tar couldn't have read that folder, so seeing it in the archive is the proof the backup worked as root.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then on the Zima
&lt;/h2&gt;

&lt;p&gt;Copy the two scripts over, dry run, back up, verify. The backup runs &lt;code&gt;docker run --privileged -v /:/host&lt;/code&gt;, which is root on the machine, so read the scripts first. &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/recipes/00-borrowing-root-from-docker.md" rel="noopener noreferrer"&gt;Recipe 00&lt;/a&gt; explains that one-liner bit by bit.&lt;/p&gt;

&lt;p&gt;ZimaOS doesn't run cron, so the schedule lives on another always-on box that SSHes in with &lt;code&gt;remote-trigger.sh&lt;/code&gt;. Nothing gets installed on the Zima.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three new recipes this week
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;03 Apps CasaOS can't eat.&lt;/strong&gt; My Homepage dashboard vanished from CasaOS twice. The second time the compose file I'd restored from was gone too. It runs as a plain &lt;code&gt;docker run&lt;/code&gt; now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;04 Uptime Kuma on the Zima.&lt;/strong&gt; Upgrading to the newer full image didn't remove the actively exploited Chromium bugs inside it. The &lt;code&gt;-slim&lt;/code&gt; image did, and it's 540 MB instead of 1.71 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;05 Disk pressure and a coldstore.&lt;/strong&gt; I nearly moved data to the Zima to free space elsewhere. The space was Docker images, which can't live on NFS anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Anything on a real Zima. The weekly run proves the scripts' root trick and the verify checks on Ubuntu with Docker. ZimaOS differences (BusyBox &lt;code&gt;find&lt;/code&gt;, read-only root, no cron) are written up from my own box, not tested in CI.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When did you last actually restore something from your homelab backups?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tutorial</category>
      <category>linux</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I rebuilt warden's console: 16 tabs down to 6 (and found my own DB world-readable)</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:34:49 +0000</pubDate>
      <link>https://dev.to/iam-tech/i-rebuilt-wardens-console-16-tabs-down-to-6-and-found-my-own-db-world-readable-4kkb</link>
      <guid>https://dev.to/iam-tech/i-rebuilt-wardens-console-16-tabs-down-to-6-and-found-my-own-db-world-readable-4kkb</guid>
      <description>&lt;p&gt;warden is my homelab security console — it reads the logs my WAF can't see, scans the boxes, watches my AI agents, and proposes bans and patches I approve with a ✅ in Discord. It never does anything on its own. I &lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;wrote up what it does&lt;/a&gt; before; this is about the cleanup I just did, because it had got messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  It had grown 16 tabs
&lt;/h2&gt;

&lt;p&gt;Every time I added something — incidents, agent tracking, vulnerabilities, endpoint integrity — it got its own tab. Dashboards grow like that. One day I counted and it was &lt;strong&gt;16 tabs across four groups&lt;/strong&gt;, and I realised I was hunting for things I used every day.&lt;/p&gt;

&lt;p&gt;So I cut it down to &lt;strong&gt;6&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overview&lt;/strong&gt; — the dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review&lt;/strong&gt; — the one place for everything waiting on my ✅ / ❌&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threats&lt;/strong&gt; — detections, edge, network, incidents, geo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; — what my AI agents are reaching out to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Posture&lt;/strong&gt; — attack surface, vulnerabilities, endpoints, health&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Settings&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The related views sit under a little sub-nav inside each tab, so nothing's gone — it's just one click deeper. And instead of a scary number on every tab, a parent tab shows a small dot when something inside actually needs me. The real counts live where they make sense, so I don't get a meaningless "512" on a tab that's really just counting known CVEs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu5e83nwcxb2522ibv02.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu5e83nwcxb2522ibv02.png" alt="warden's new six-tab console on demo data" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The small things that were quietly annoying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The settings page listed raw config keys like &lt;code&gt;patch.expire_hours&lt;/code&gt; and &lt;code&gt;cloudflare.zone_id&lt;/code&gt;. Now each field has a plain label ("Approval expires after (hours)", "Cloudflare Zone ID") with the raw key dimmed underneath, so I still know what maps to the config file.&lt;/li&gt;
&lt;li&gt;API tokens and the "how do I connect a tool" bits were off on their own. They live under &lt;strong&gt;Account&lt;/strong&gt; now, where I'd actually look for them.&lt;/li&gt;
&lt;li&gt;The approvals queue used to sit on top of the Settings page. It's its own &lt;strong&gt;Review&lt;/strong&gt; tab now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bit that mattered: my database was world-readable
&lt;/h2&gt;

&lt;p&gt;While I was in there I checked how warden stores anything sensitive. Most of it was already fine — API tokens are only kept as a SHA-256 hash, passwords are scrypt, session cookies are random and only their hash is stored, and actual secrets (bot tokens, API keys) never touch the database at all.&lt;/p&gt;

&lt;p&gt;Then I looked at the file permissions on the database itself. &lt;strong&gt;644. World-readable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a shared box that's a real problem: the database holds logs, session hashes, and — if you use warden's own 2FA — the TOTP seed. Anything else running on that machine as another user could just read it. In my case that includes the sandboxed coding agent I let loose on my repos, which is exactly the kind of thing warden exists to keep honest.&lt;/p&gt;

&lt;p&gt;Fix was simple but I made it stick: warden now forces the database (and its write-ahead files) to &lt;code&gt;0600&lt;/code&gt; on every start, and each process sets a umask so anything it creates is owner-only from birth. I tested it by deliberately setting it back to 644 and restarting — it put itself straight back to 600.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: the dangerous gaps aren't the exotic ones. It was a default file permission sitting there the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;It's &lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;, AGPL-3.0, dependency-free Python — it'll run on a Pi. The &lt;a href="https://github.com/casareanderson/warden/releases/tag/v3.2.0" rel="noopener noreferrer"&gt;v3.2 release&lt;/a&gt; has the full changelog, and there's a &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;5-minute demo setup&lt;/a&gt; if you want to click around before wiring it to anything.&lt;/p&gt;

&lt;p&gt;Still detect-only and read-only by default. It tells me what it'd do; I decide.&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>agents</category>
      <category>database</category>
    </item>
    <item>
      <title>Set up a GPU watch on a ZimaBlade: get told when the card falls off</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:00:19 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-a-gpu-watch-on-a-zimablade-get-told-when-the-card-falls-off-54ob</link>
      <guid>https://dev.to/iam-tech/set-up-a-gpu-watch-on-a-zimablade-get-told-when-the-card-falls-off-54ob</guid>
      <description>&lt;p&gt;My ZimaBlade has a Quadro P600 on a powered riser for Immich. It works, for a while, then logs &lt;code&gt;Xid 79, GPU has fallen off the bus&lt;/code&gt; and stays gone until a reboot. The nasty part is Immich doesn't break. It quietly falls back to the CPU and carries on, slowly. Mine went six days before I noticed.&lt;/p&gt;

&lt;p&gt;So the &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich" rel="noopener noreferrer"&gt;GPU repo&lt;/a&gt; has a new script: &lt;code&gt;zima-gpu-watch.sh&lt;/code&gt;. It checks every few minutes, logs how long each boot's GPU lasts, and alerts once when it goes and once when it's back. Not every five minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdds3c9f7fxz7edzu30j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdds3c9f7fxz7edzu30j.png" alt="The watcher run against the real ZimaBlade, whose GPU was off the bus at 408 hours of uptime, then the alive and dead check from the weekly setup run, alerting once per change" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That first line is my actual Zima this morning: dead, 408 hours into the boot. The rest is the check the &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; runs every week on a fresh machine, using a stand-in for &lt;code&gt;nvidia-smi&lt;/code&gt; so it can fake alive and dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose first
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/casareanderson/zimablade-gpu-immich &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;zimablade-gpu-immich
bash diagnostics/zima-gpu-check.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine read-only sections. As a normal user, five are accurate and the rest tell you they need root (on ZimaOS that's the privileged-container route in the guide). Read section 04 first: &lt;code&gt;width 0&lt;/code&gt; is a physical fault no driver will fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then watch it
&lt;/h2&gt;

&lt;p&gt;From another always-on box with an SSH key to the Zima:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ZIMA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@zima &lt;span class="nv"&gt;GPU_MATCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;P600 &lt;span class="nv"&gt;WEBHOOK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://discord.com/api/webhooks/... bash diagnostics/zima-gpu-watch.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that in cron every 15 minutes. Two rules it follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unreachable isn't dead.&lt;/strong&gt; If SSH can't reach the Zima (rebooting, network blip) it logs &lt;code&gt;unreachable&lt;/code&gt; and doesn't touch the state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One alert per change.&lt;/strong&gt; First run just records the starting state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;The real hardware. The weekly run proves both scripts run cleanly on a machine with no GPU and that the alive/dead logic alerts exactly once per change. Whether your card stays on the bus is between you, your riser cable and your grounds.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anyone got a riser setup in a small box that's actually stable? What fixed it?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>linux</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Set up warden in 5 minutes: try my homelab security console on demo data</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:00:21 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-warden-in-5-minutes-try-my-homelab-security-console-on-demo-data-28bn</link>
      <guid>https://dev.to/iam-tech/set-up-warden-in-5-minutes-try-my-homelab-security-console-on-demo-data-28bn</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;warden&lt;/a&gt; is my in-house security console: it reads the logs, scans the machines, and proposes bans and patches that I approve with a ✅ in Discord. It never does anything on its own.&lt;/p&gt;

&lt;p&gt;The usual problem with a tool like this is you have to wire it to everything before you can see if you even like it. So the setup guide starts with demo data. Five minutes, nothing to connect.&lt;/p&gt;

&lt;p&gt;The guide is tested too. A GitHub job pulls the commands out of &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt; and runs them on a fresh Ubuntu machine every week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cqsa0r6p8ua9tlv1qpz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cqsa0r6p8ua9tlv1qpz.png" alt="warden's demo steps on a fresh GitHub runner: demo data, a user, the sign-in redirect, the REST summary and the setup check" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;Linux with systemd and Python 3. Tested on Ubuntu 24.04 and Debian 13.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;python3-yaml python3-requests nmap
&lt;span class="nb"&gt;sudo &lt;/span&gt;git clone https://github.com/casareanderson/warden.git /opt/warden
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /opt/warden &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/warden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Look before you wire anything
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WARDEN_DATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/warden-demo
python3 tools/demo.py
./warden-setup user add demo &lt;span class="nt"&gt;--role&lt;/span&gt; admin
python3 warden-ui.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://127.0.0.1:8792" rel="noopener noreferrer"&gt;http://127.0.0.1:8792&lt;/a&gt; and sign in as &lt;code&gt;demo&lt;/code&gt; with the one-time password it printed. You'll be asked to pick your own.&lt;/p&gt;

&lt;p&gt;It also has a REST API and an MCP endpoint, so an AI agent can ask it "anything dodgy last night?". Mint a token and try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./warden-setup token try-it | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://127.0.0.1:8792/api/v1/summary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get JSON with &lt;code&gt;"mode": "detect-only"&lt;/code&gt; and the headline counts. Every MCP tool is read-only, and a test enforces that: an agent can ask, it can't ban or patch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the real install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./warden-setup init                  &lt;span class="c"&gt;# asks about your estate, alerts and machines&lt;/span&gt;
./warden-setup check &lt;span class="nt"&gt;--hosts&lt;/span&gt;         &lt;span class="c"&gt;# ✓ / ✗ / ○ for every feature&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./warden-setup units &lt;span class="nt"&gt;--install&lt;/span&gt;  &lt;span class="c"&gt;# only turns on the timers you need&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; is the bit I like. It asks the running system what actually works, and tells you what to do about each ✗. It exits 1 while anything required is missing, so it works in a script too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Everything after the demo. &lt;code&gt;init&lt;/code&gt; asks you questions, and the real install talks to your own machines, logs and Cloudflare account, so the weekly run stops at "the example config loads and the checks run". The guide says that next to each step.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/warden/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What would you want a homelab security console to tell you first thing in the morning?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>linux</category>
    </item>
    <item>
      <title>Set up sieve in 5 minutes: find the CVEs that matter on a Linux box</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Mon, 26 Oct 2026 09:00:20 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-sieve-in-5-minutes-find-the-cves-that-matter-on-a-linux-box-2ebh</link>
      <guid>https://dev.to/iam-tech/set-up-sieve-in-5-minutes-find-the-cves-that-matter-on-a-linux-box-2ebh</guid>
      <description>&lt;p&gt;I wrote &lt;a href="https://github.com/casareanderson/sieve" rel="noopener noreferrer"&gt;sieve&lt;/a&gt; last week to replace trivy in my homelab. A few people asked how to actually get it going, so this is the short version. The long version is &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt; in the repo.&lt;/p&gt;

&lt;p&gt;One thing I've done differently this time: the guide is tested. A GitHub job pulls the commands out of SETUP.md and runs them on a brand new Ubuntu machine every week. If a step breaks, the job goes red before you find out the hard way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqx3ope44sqwkecxj9da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqx3ope44sqwkecxj9da.png" alt="sieve's setup steps running on a fresh GitHub runner: install with pipx, scan the host, scan a running Debian 12 container" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's a real run from today. Funny thing is, a fresh GitHub runner isn't clean: 32,445 findings, 9 of them actively exploited (all kernel tool packages with no fix yet), 31 fixable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;You need Linux and Python 3.10 or newer. No database, no server, no API key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/casareanderson/sieve
sieve &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;command not found&lt;/code&gt;, run &lt;code&gt;pipx ensurepath&lt;/code&gt; and open a new terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan the box you're on
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sieve host &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First run is the slow one, it downloads the CISA known-exploited list and the EPSS scores and asks OSV.dev about every package. After that it's cached.&lt;/p&gt;

&lt;p&gt;Read it top down. A ⚠ in the KEV column means someone's exploiting it right now. A massive number with "0 fixable" is normal on a patched box, most of it is "affected, no fix released yet".&lt;/p&gt;

&lt;p&gt;The exit code is the useful bit for cron: &lt;code&gt;0&lt;/code&gt; nothing known-exploited, &lt;code&gt;1&lt;/code&gt; at least one KEV finding, &lt;code&gt;2&lt;/code&gt; part of the scan failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan your containers
&lt;/h2&gt;

&lt;p&gt;This needs root, because container filesystems live under &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/root&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; sieve&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; containers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the collector on another Docker host with nothing installed there except python3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sieve collector | ssh root@dockerhost python3 - &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; packages.tgz
sieve containers &lt;span class="nt"&gt;--from-tar&lt;/span&gt; packages.tgz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Put it on a timer
&lt;/h2&gt;

&lt;p&gt;The line I'd actually use, only shouts about things you can fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sieve"&gt;&lt;code&gt;&lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;7&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;  &lt;span class="nx"&gt;sieve&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;fixable&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;only&lt;/span&gt; &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a Docker image and a GitHub Action, both in the guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Not much for this one. The weekly run covers install, host scan, JSON output, a real container scan and the Docker image. It can't tell you how your own distro feeds behave: Fedora, RHEL and Arch get their Python and container language packages checked, not the OS ones.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/sieve/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anyone else running a scanner on a timer at home? What do you do with the "no fix yet" pile?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>Everything I've open-sourced from my homelab, in one list</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/everything-ive-open-sourced-from-my-homelab-in-one-list-1gke</link>
      <guid>https://dev.to/iam-tech/everything-ive-open-sourced-from-my-homelab-in-one-list-1gke</guid>
      <description>&lt;p&gt;People keep finding one of my repos and not the others, so here's the lot in one place. Everything below is free and on GitHub, and every one now has a setup guide in &lt;code&gt;docs/SETUP.md&lt;/code&gt; that a GitHub job runs on a fresh machine every week. If a guide stops working, the job goes red, so I find out before you do.&lt;/p&gt;

&lt;p&gt;Each guide also says what CI &lt;em&gt;can't&lt;/em&gt; prove (anything that needs your hardware or accounts). That bit's in there on purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8phimsvvgxizzzqvc7k6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8phimsvvgxizzzqvc7k6.png" alt="warden's console: detections, vulnerabilities and decisions waiting for approval" width="800" height="987"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;warden&lt;/a&gt;&lt;/strong&gt;: a homelab security console. Reads the logs your WAF can't see, scans your machines, proposes bans and patches, and waits for your ✅. Detect-only by default. For anyone self-hosting behind a Cloudflare tunnel. &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/sieve" rel="noopener noreferrer"&gt;sieve&lt;/a&gt;&lt;/strong&gt;: finds the CVEs that matter on a Linux box and its running containers, using OSV.dev, CISA's known-exploited list and EPSS. No scanner binary, no database, no server. &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/farrier" rel="noopener noreferrer"&gt;farrier&lt;/a&gt;&lt;/strong&gt;: owner-approved patching as a standalone CLI. Plan, snapshot, apply (apt, npm, pip), and it always hands you the rollback. &lt;a href="https://github.com/casareanderson/farrier/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Homelab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;zimablade-recipes&lt;/a&gt;&lt;/strong&gt;: running a ZimaBlade like a server. Root through Docker, AppData backups that actually include your certificates, offsite photos, apps CasaOS can't remove, a slimmer Uptime Kuma. &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/zimablade-gpu-immich" rel="noopener noreferrer"&gt;zimablade-gpu-immich&lt;/a&gt;&lt;/strong&gt;: a GPU in a ZimaBlade for Immich. The slot's real limits, the driver trap for older cards, and a watcher that tells you when the card falls off the bus. &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/garden-room-lights" rel="noopener noreferrer"&gt;garden-room-lights&lt;/a&gt;&lt;/strong&gt;: time-of-day lighting for a room of WLED strips, with brightness that respects each strip's power limit. &lt;a href="https://github.com/casareanderson/garden-room-lights/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/WLED_Voron--F1-Lights" rel="noopener noreferrer"&gt;WLED_Voron--F1-Lights&lt;/a&gt;&lt;/strong&gt;: Klipper macros that turn a Voron 2's LED strip an F1 colour for each print state. &lt;a href="https://github.com/casareanderson/WLED_Voron--F1-Lights/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI agents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/model-radar" rel="noopener noreferrer"&gt;model-radar&lt;/a&gt;&lt;/strong&gt;: finds free models that really make tool calls, and fails your agents over when the paid model or the credit runs out. &lt;a href="https://github.com/casareanderson/model-radar/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;toolsmith&lt;/a&gt;&lt;/strong&gt;: a Hermes agent that researches a replacement for one of your tools each week, tests it in a sandbox, and withholds anything it can't back with a measured fact. Needs Hermes and Proxmox. &lt;a href="https://github.com/casareanderson/toolsmith/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/ephor" rel="noopener noreferrer"&gt;ephor&lt;/a&gt;&lt;/strong&gt;: passive, detect-only monitor of what your AI agents connect to, download and can access. Reads &lt;code&gt;/proc&lt;/code&gt; and the socket table, so nothing can route around it. &lt;a href="https://github.com/casareanderson/ephor/blob/main/README.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/proctor" rel="noopener noreferrer"&gt;proctor&lt;/a&gt;&lt;/strong&gt;: an AI code reviewer you run from a terminal. Point it at a diff, it reports what's wrong and fact-checks every finding against the patch. &lt;a href="https://github.com/casareanderson/proctor/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Music
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/setlisted" rel="noopener noreferrer"&gt;setlisted&lt;/a&gt;&lt;/strong&gt;: a headless Spotify CLI for non-repeating discovery mixes, hour-by-hour event sets and playlist flow. &lt;a href="https://github.com/casareanderson/setlisted/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/spin-that-dice" rel="noopener noreferrer"&gt;spin-that-dice&lt;/a&gt;&lt;/strong&gt;: a jukebox dice for parties. Tap the die on a tablet, get a random track from one of 43 categories of music, and it plays. &lt;a href="https://github.com/casareanderson/spin-that-dice/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Shopping and eBay
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/sold-shadow" rel="noopener noreferrer"&gt;sold-shadow&lt;/a&gt;&lt;/strong&gt;: build your own eBay sold-price data from the official API, and measure whether it can replace the paid data you use. &lt;a href="https://github.com/casareanderson/sold-shadow/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cex-uk-prices" rel="noopener noreferrer"&gt;cex-uk-prices&lt;/a&gt;&lt;/strong&gt;: CeX UK sell, cash and voucher prices and store stock, with the traps measured (£0 means they won't buy it, voucher isn't cash). &lt;a href="https://github.com/casareanderson/cex-uk-prices/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Writing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/blog-kit" rel="noopener noreferrer"&gt;blog-kit&lt;/a&gt;&lt;/strong&gt;: what I publish these posts with. A dev.to publisher with a leak check that refuses private IPs and keys, plus diagrams and covers. &lt;a href="https://github.com/casareanderson/blog-kit/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Just for fun
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk" rel="noopener noreferrer"&gt;cryptonym-desk&lt;/a&gt;&lt;/strong&gt;: name the films and characters you like and it issues you a spy-style cryptonym. One HTML file. &lt;a href="https://github.com/casareanderson/cryptonym-desk/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity" rel="noopener noreferrer"&gt;cryptonym-desk-sanity&lt;/a&gt;&lt;/strong&gt;: the same desk rebuilt on Astro and a public Sanity dataset. &lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of these is useful to you, a star on GitHub genuinely helps people find it. Which one should get a proper write-up next?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>linux</category>
      <category>ai</category>
    </item>
    <item>
      <title>16 containers on 2 cores: what my ZimaBlade home server actually uses</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/16-containers-on-2-cores-what-my-zimablade-home-server-actually-uses-1p57</link>
      <guid>https://dev.to/iam-tech/16-containers-on-2-cores-what-my-zimablade-home-server-actually-uses-1p57</guid>
      <description>&lt;p&gt;People ask if a little box like a ZimaBlade can really run a homelab. Mine runs 16 containers on a dual-core Celeron N3350 from 2016. So I measured what each one actually uses.&lt;/p&gt;

&lt;p&gt;The box: 2 cores (1.1 GHz, boosts to 2.4), 16 GB of RAM, two 6 TB IronWolf drives on SATA. It had been up 17 days when I took these, and Docker hadn't restarted a single container.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAM: Immich is most of it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwyx6953rf7nzwew3iad.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwyx6953rf7nzwew3iad.png" alt="docker stats on the ZimaBlade: per-container CPU and memory" width="799" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Added up, the 16 containers use just under 3 GB. The Immich stack (server, Postgres, machine learning, Redis) is about 2.2 GB of that, roughly three quarters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;immich-server: 1.34 GB&lt;/li&gt;
&lt;li&gt;immich-postgres: 602 MB&lt;/li&gt;
&lt;li&gt;immich-machine-learning: 239 MB&lt;/li&gt;
&lt;li&gt;uptime-kuma: 183 MB&lt;/li&gt;
&lt;li&gt;npmplus (reverse proxy): 137 MB&lt;/li&gt;
&lt;li&gt;homepage: 112 MB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is tiny. Authelia, the thing every login goes through, is 32 MB. The Cloudflare tunnel is 29 MB. CrowdSec is 57 MB.&lt;/p&gt;

&lt;p&gt;The OS says 5.4 GB "used" overall, but 10.5 GB is still available because the rest is file cache it'll hand back. It's also got about 1 GB sitting in swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  CPU: idle, mostly
&lt;/h2&gt;

&lt;p&gt;Two snapshots 20 seconds apart and almost everything was under 1% CPU. The busiest were Uptime Kuma (4% then 1%) and immich-server (under 1%, then nearly 3%). Earlier in the day the load average sat around 0.9 on 2 cores, so about half busy, and it sat at 41-44 °C.&lt;/p&gt;

&lt;p&gt;So for always-on stuff that mostly waits for requests, 2 old cores are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 2 cores hurt
&lt;/h2&gt;

&lt;p&gt;Here's the bit I didn't expect. While I was taking these measurements, the 1-minute load jumped from about 0.9 to 3.4 and the kernel's CPU pressure figure said something was stuck waiting for CPU about 75% of the last 10 seconds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vl23liffzskshgcll6b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vl23liffzskshgcll6b.png" alt="Load average and CPU pressure while measuring" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; itself isn't free, and I can't promise nothing else kicked off at the same time. But it shows the shape: with only 2 cores, any burst queues up behind everything else. Idle services are cheap. Bursty work, like ML indexing, video transcoding or someone uploading 300 photos, is where this CPU runs out. And my Immich machine learning runs on the CPU (the P600 is too old for Immich's GPU build), so that work lands on these same 2 cores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disk, not CPU, is the other limit
&lt;/h2&gt;

&lt;p&gt;The fast storage is a 17 GB eMMC partition, now 72% full, mostly a 5.2 GB swap file and 3 GB of OS update files. Docker's images (12.5 GB, 5.4 GB of it old ones I could prune) live on the big array, which is only 36% used. So the thing I watch isn't CPU or RAM, it's that little eMMC.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means if you're picking hardware
&lt;/h2&gt;

&lt;p&gt;From my numbers, not a benchmark:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RAM was the right thing to have plenty of. 16 GB means Immich can be greedy and nothing else notices.&lt;/li&gt;
&lt;li&gt;The CPU is fine for services that idle. It's not fine for bursts, so keep heavy jobs off it or schedule them.&lt;/li&gt;
&lt;li&gt;Real SATA mattered more to me than CPU speed. Photos live on proper drives, not USB.&lt;/li&gt;
&lt;li&gt;On paper I'd expect a Raspberry Pi 5 to beat this CPU. I haven't benchmarked that yet. That's the next post, with prices and power once I've got real numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pi or mini PC for your homelab? And what's the biggest RAM hog on yours?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>performance</category>
      <category>ai</category>
      <category>learning</category>
      <category>devops</category>
    </item>
    <item>
      <title>What a ZimaBlade can't do: the limits I hit running it as my home server</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:00:19 +0000</pubDate>
      <link>https://dev.to/iam-tech/what-a-zimablade-cant-do-the-limits-i-hit-running-it-as-my-home-server-2ig5</link>
      <guid>https://dev.to/iam-tech/what-a-zimablade-cant-do-the-limits-i-hit-running-it-as-my-home-server-2ig5</guid>
      <description>&lt;p&gt;I've run a ZimaBlade as my main home server for a few months now. It does photos (Immich), single sign-on, the reverse proxy, monitoring, CrowdSec and a Cloudflare tunnel. I like it. But it's a small board and it has proper limits, and most of them I found the hard way.&lt;/p&gt;

&lt;p&gt;I already wrote up &lt;a href="https://dev.to/iam-tech/running-a-zimablade-like-a-server-not-an-appliance-48ol"&gt;how I set it up as a server rather than an appliance&lt;/a&gt; and &lt;a href="https://dev.to/iam-tech/a-dead-gpu-degrades-gracefully-a-working-one-crash-loops-bdj"&gt;the saga of the Quadro P600 in it&lt;/a&gt;. This one is the list I wish I'd had before buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Power comes in over USB-C, and there isn't much of it
&lt;/h2&gt;

&lt;p&gt;The stock power brick is 12 V / 3 A, so 36 W, over USB-C. Kits since late 2025 come with a 60 W one. The board, RAM and two SATA drives already sit around 25-30 W, so there is no headroom for anything else.&lt;/p&gt;

&lt;p&gt;I got this wrong in my own repo at first. I'd written instructions about a barrel jack. There is no barrel jack, it's USB-C. Fixed now, but if you're planning to power a riser or a GPU off the same supply, don't. Anything hungry needs its own PSU.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The PCIe slot is small in every direction
&lt;/h2&gt;

&lt;p&gt;What the slot actually reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PCIe 2.0 x4, so about 2 GB/s at best&lt;/li&gt;
&lt;li&gt;a 25 W slot power limit, so a 40 W card can't run off it directly&lt;/li&gt;
&lt;li&gt;no hot-plug: a card only gets found at a cold boot, rescanning does nothing&lt;/li&gt;
&lt;li&gt;no Above 4G decoding: the biggest PCI window is about 1.25 GB, fine for a 2 GB card, probably a wall for anything with big VRAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "stick a GPU in it" really means a riser, an external PSU and a small, old card.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two power supplies, two grounds, one very confused GPU
&lt;/h2&gt;

&lt;p&gt;My riser setup ran the Zima off its own unearthed brick and the GPU off an earthed ATX PSU. Two ground domains, joined only by the thin grounds in the riser's cable. The fan spun, so I assumed power was fine. The PCIe link never trained at all.&lt;/p&gt;

&lt;p&gt;After a lot of physical faff in the loft it did enumerate. Then it ran for about 8.5 hours and dropped off:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F783lwvcbvhgb9fhjwf6u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F783lwvcbvhgb9fhjwf6u.png" alt="The kernel log when the GPU fell off the bus" width="800" height="199"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The link had also dropped itself to PCIe 1.0 x1. These days a small watcher script records how long the card survives each boot and only shouts when it changes state, rather than every 15 minutes. The full story's in the P600 post.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The OS is read-only and you don't get sudo
&lt;/h2&gt;

&lt;p&gt;ZimaOS is immutable. &lt;code&gt;/&lt;/code&gt; is a read-only squashfs that always shows 100% full. That's by design, it's not a full disk, but it scared me the first time.&lt;/p&gt;

&lt;p&gt;The one SSH user can run Docker without sudo, but sudo itself wants a password I don't have. So editing anything root-owned (a systemd unit, &lt;code&gt;/etc/exports&lt;/code&gt;) means borrowing root through a privileged container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; &lt;span class="nt"&gt;--pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="nt"&gt;-v&lt;/span&gt; /:/host alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;chroot&lt;/span&gt; /host systemctl status some-unit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that looks dodgy, it is a bit. But being in the docker group already means being root in all but name, so it's not adding a hole, it's just honest about the one that's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The app store deleted my dashboard. Twice.
&lt;/h2&gt;

&lt;p&gt;I ran Homepage as a CasaOS app. In July it vanished: container gone, config still on disk. I reinstalled it from the leftover compose file. In September it vanished again, and this time the compose file had gone too.&lt;/p&gt;

&lt;p&gt;Now it's a plain &lt;code&gt;docker run&lt;/code&gt; with &lt;code&gt;--restart unless-stopped&lt;/code&gt;, and CasaOS has no say in whether it lives. Same for Uptime Kuma when I moved it onto the Zima. Anything I care about stays out of the app store.&lt;/p&gt;

&lt;p&gt;Related trap: back in July five of my containers had &lt;code&gt;restart=no&lt;/code&gt;. CasaOS starts them at boot, so it looked fine, but a container that crashed mid-life just stayed dead. One sat dead for six days before I noticed. I switched them all to &lt;code&gt;unless-stopped&lt;/code&gt; then. When I checked again for this post, 8 of the 16 running were on &lt;code&gt;restart=no&lt;/code&gt; again, so it's a check worth repeating.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. 17 GB of eMMC fills up fast
&lt;/h2&gt;

&lt;p&gt;The fast storage is a 29 GB eMMC, with a 17 GB &lt;code&gt;/DATA&lt;/code&gt; partition. The two big drives are a separate 5.5 TB array.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl56qutmszitws7npaszw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl56qutmszitws7npaszw.png" alt="df and docker system df on the ZimaBlade: eMMC 72% full, array 36%" width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;12 GB used and 4.7 GB free. The biggest things on it are a 5.2 GB swap file and 3 GB of OS update files. Docker's 12.5 GB of images (5.4 GB of that is old images I could prune) actually sit on the array. Rule I follow now: anything big or cold goes on the array, never &lt;code&gt;/DATA&lt;/code&gt;. I also share a "coldstore" folder off the array to my Proxmox boxes over NFS, mounted with &lt;code&gt;nofail&lt;/code&gt; and automount so a Zima outage can never stop them booting.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. It can't watch itself
&lt;/h2&gt;

&lt;p&gt;Uptime Kuma runs on the Zima. Which means when the Zima dies, the thing that's meant to tell me is dead too. So a script on a different box checks Kuma every 5 minutes and restarts the container over SSH if it's gone. I tested it by stopping Kuma, it was back in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  So would I buy one again?
&lt;/h2&gt;

&lt;p&gt;Yeah, for what it is. Two real SATA ports, 16 GB of RAM and an x86 CPU that runs every Docker image without thinking about ARM builds is a lot in a box that size. But it's a 2016-era dual-core Celeron with a weedy PCIe slot and a USB-C power brick, and you have to work around that rather than pretend it's a NAS appliance. Next post is what 16 containers actually cost it.&lt;/p&gt;

&lt;p&gt;What's the limit that bit you on your homelab box?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>FontForge brand fonts: why changeWeight failed and offset outlines worked</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:00:37 +0000</pubDate>
      <link>https://dev.to/iam-tech/fontforge-brand-fonts-why-changeweight-failed-and-offset-outlines-worked-24bh</link>
      <guid>https://dev.to/iam-tech/fontforge-brand-fonts-why-changeweight-failed-and-offset-outlines-worked-24bh</guid>
      <description>&lt;p&gt;I run two side projects that look nothing alike on purpose. The tech one (homelab notes, tips, the Server reels) is black and acid green with IBM Plex Sans Condensed Bold for headlines. The poster one is gold on black with Anton. Both fonts are free and brilliant, which is the problem. Half the internet uses them.&lt;/p&gt;

&lt;p&gt;On 29 September I wanted something "a little off what we use, sharper lines, more impact". Not a new typeface, I'm not a type designer. Just my existing fonts, a bit heavier with a signature detail, so a headline looks like mine before you read it.&lt;/p&gt;

&lt;p&gt;Here's what broke, the licence rules, and the colours, which were less tidy than I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools
&lt;/h2&gt;

&lt;p&gt;FontForge has Python bindings (&lt;code&gt;apt install python3-fontforge&lt;/code&gt; on Debian/Ubuntu). The whole build is one 82-line script: open the source font, loop over every glyph, change the outline, rename the font, write &lt;code&gt;.otf&lt;/code&gt; and &lt;code&gt;.ttf&lt;/code&gt;, then render a specimen with Pillow so I can actually see it.&lt;/p&gt;

&lt;p&gt;Two fonts come out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iC Volt Condensed&lt;/strong&gt;, from IBM Plex Sans Condensed Bold, for the tech brand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C1 Cut&lt;/strong&gt;, from Anton Regular, for the poster brand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomd36h4bcmmz05fqkmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomd36h4bcmmz05fqkmx.png" alt="Specimen: Anton vs C1 Cut in gold, then Plex Sans Condensed Bold vs iC Volt Condensed in volt green, each with a headline line and a body line with digits" width="799" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: changeWeight made a mess
&lt;/h2&gt;

&lt;p&gt;FontForge has a built-in for this, &lt;code&gt;glyph.changeWeight()&lt;/code&gt;. One call per glyph, give it a stroke amount, job done. So v1 did exactly that: 3% of the em for the Anton one, 3.4% for Plex, then tightened the letter spacing by 1.2% and 1.8%.&lt;/p&gt;

&lt;p&gt;It never errored. It was wrapped in a &lt;code&gt;try/except: pass&lt;/code&gt; (not proud of that), so I went back and counted: 596 glyphs on Anton, 330 on Plex, zero exceptions.&lt;/p&gt;

&lt;p&gt;It just looked rubbish. The heavy Anton one clogged up, the holes in R, G and 8 nearly closed. The R leg grew a weird curled foot. The S on the Plex one got little nicks at its ends. And the tighter spacing made the clogging worse, letters started kissing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumduqdal7wju5v4kefpk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumduqdal7wju5v4kefpk.png" alt="Close-up: v1 changeWeight on the left vs v2 offset with mitre joins on the right, for both fonts. v1 has clogged counters, a curled R foot and nicked S terminals" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That left-hand side is a rebuild of v1 from the old script today, so it's real output, not a mock up.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: offset the outline yourself
&lt;/h2&gt;

&lt;p&gt;What I actually wanted was "the same letter, a bit fatter, corners still sharp". So v2 does that directly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;removeOverlap()&lt;/code&gt; so the glyph is one clean outline&lt;/li&gt;
&lt;li&gt;take a copy of the outline and stroke it with a circular pen, butt caps and &lt;strong&gt;mitre joins&lt;/strong&gt;, keeping only the outer band&lt;/li&gt;
&lt;li&gt;add that band back onto the original body and merge it with &lt;code&gt;removeOverlap()&lt;/code&gt; again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mitre join is the bit that matters. A round join would soften every corner, which is the opposite of "sharper". A mitre keeps the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;circular&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;butt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;miter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;removeinternal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt;
&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeOverlap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v2 also uses way less weight: 1% of the em on C1 Cut, 2% on iC Volt. A little goes a long way when the shapes aren't getting mangled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cut corners
&lt;/h2&gt;

&lt;p&gt;The signature detail is a chamfer, like a machined edge. For every corner point the script checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the point and both neighbours are on-curve, so it's a straight line meeting a straight line&lt;/li&gt;
&lt;li&gt;both edges are longer than 3x the chamfer size, so tiny details don't get eaten&lt;/li&gt;
&lt;li&gt;the angle is between 60° and 120°&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all three pass, the corner point gets swapped for two points a set distance back along each edge. That's 3% of the em on C1 Cut and 2.8% on iC Volt. Curves are never touched, so S, G and 8 keep their shape and only the hard corners on things like M, T and K get cut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiauxpeui0o2jyzwl0utk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiauxpeui0o2jyzwl0utk.png" alt="Close-up of MTK: the original fonts on the left, the new ones on the right with small 45 degree cuts on the corners" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that it's housekeeping: &lt;code&gt;correctDirection()&lt;/code&gt;, &lt;code&gt;addExtrema()&lt;/code&gt;, &lt;code&gt;round()&lt;/code&gt;, then spacing. C1 Cut went 0.4% looser, iC Volt 0.6% tighter, split evenly across both side bearings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licence bit (do read this)
&lt;/h2&gt;

&lt;p&gt;Both source fonts are under the SIL Open Font License 1.1. It's a generous licence, but it has rules for modified versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reserved Font Names.&lt;/strong&gt; IBM's licence for Plex says "Reserved Font Name 'Plex'". A modified version can't use that name. So no "Plex Volt" or anything like it, hence iC Volt Condensed. Anton doesn't reserve a name, but I renamed it to C1 Cut anyway so nobody mixes it up with the real thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename it properly.&lt;/strong&gt; The script strips the old Family, Full name, PostScript name, Unique ID, Preferred Family, WWS and Trademark entries from the name table and writes new ones. It appends "Modified 2026 ... (SIL Open Font License 1.1)" to the copyright, it doesn't replace it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the licence with the font.&lt;/strong&gt; And the modified font stays under the OFL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't sell the font files on their own.&lt;/strong&gt; You can use them in stuff you sell, but the font itself isn't a product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where I got it wrong: the first licence file the script wrote for iC Volt was a three-line notice, credit and a link, without the actual licence text. When the font went on my site I shipped the proper file instead: the notice plus the full OFL 1.1 text, served next to the font with a "Font licence" link in the footer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they're used right now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;iC Volt&lt;/strong&gt; went live on 8 October on my tech site, headlines only. It's a Latin-only subset made with fontTools, 12,232 bytes as WOFF2. Body text stays IBM Plex Sans and Plex Mono. The Friday newsletter template uses it too, with Impact as the fallback because loads of email clients won't load web fonts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C1 Cut&lt;/strong&gt; isn't used anywhere yet. The poster renderers still use Anton, and the tech Instagram renderer still uses Plex Condensed Bold. Swapping is mostly changing a font constant in each renderer, but I'd rather the owner of each brand (me, wearing a different hat) looks at it on real posts first.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the colours
&lt;/h2&gt;

&lt;p&gt;I'd been calling these "colour packs" in my head. Being honest, there's no colour pack system. What exists:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferpd5o247s3qxobq6934.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferpd5o247s3qxobq6934.png" alt="Palette swatches rendered from the hex values in the code: the eight IAMC/TECH tokens, the THEC1LAB gold trio, and five product colourways" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tech brand&lt;/strong&gt; has one palette of eight tokens: ink &lt;code&gt;#0B0C0E&lt;/code&gt;, panel &lt;code&gt;#15181C&lt;/code&gt;, grid &lt;code&gt;#262A30&lt;/code&gt;, paper &lt;code&gt;#F1EFE8&lt;/code&gt;, mute &lt;code&gt;#8B9099&lt;/code&gt;, volt &lt;code&gt;#D7FF3A&lt;/code&gt;, red &lt;code&gt;#FF5A4E&lt;/code&gt;, green &lt;code&gt;#39D98A&lt;/code&gt;. Volt is the headline and accent colour, red and green are for the do/don't slides and result labels. It lives as a &lt;code&gt;BRAND&lt;/code&gt; dict in the Instagram renderer, as RGB tuples in the reel renderer, and as CSS variables on the site.&lt;/p&gt;

&lt;p&gt;Three copies, no shared file. And it's already drifted. The newer "Server explains" reel script defines its own red and green, &lt;code&gt;(255, 92, 92)&lt;/code&gt; and &lt;code&gt;(92, 220, 120)&lt;/code&gt;, which are close to the brand ones but not the same, plus an amber the palette doesn't have. Nobody would spot it on a phone, but that's how a brand goes muddy. Next job is one palette file that everything imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The poster brand&lt;/strong&gt; is simpler at the top: gold &lt;code&gt;#D4AF37&lt;/code&gt; on &lt;code&gt;#0B0B0B&lt;/code&gt; with &lt;code&gt;#F5F3EE&lt;/code&gt; text in the video renderer. The posters themselves use named colourways, which is the closest thing I've got to actual packs. Each one is a dict with a background, a gradient or solid fill for the glyphs, an accent, title, subtitle and fine-print colours, plus a flag for light or dark. There are five base ones (noir-gold, noir-silver, paper, neon, pop), and limited runs add their own with &lt;code&gt;COLOURWAYS.update()&lt;/code&gt; so they never reuse a base one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No errors isn't the same as working. changeWeight ran clean on 926 glyphs and still made rubbish. Render a specimen every build.&lt;/li&gt;
&lt;li&gt;Mitre joins if you want weight without losing sharp corners.&lt;/li&gt;
&lt;li&gt;Check the licence for a Reserved Font Name before picking a name.&lt;/li&gt;
&lt;li&gt;Colours in one file from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Has anyone made a proper derivative font for their own stuff? I'd like to know how you checked yours at small sizes, because I've only really looked at mine big.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>discuss</category>
      <category>career</category>
    </item>
    <item>
      <title>Why I built warden: fail2ban can't see attackers behind a Cloudflare tunnel</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:06:03 +0000</pubDate>
      <link>https://dev.to/iam-tech/why-i-built-warden-fail2ban-cant-see-attackers-behind-a-cloudflare-tunnel-a46</link>
      <guid>https://dev.to/iam-tech/why-i-built-warden-fail2ban-cant-see-attackers-behind-a-cloudflare-tunnel-a46</guid>
      <description>&lt;p&gt;I already wrote up what warden does and the bugs I hit building it in &lt;a href="https://dev.to/iam-tech/homelab-security-on-512-mb-warden-scores-my-logs-and-asks-before-it-bans-anything-2hob"&gt;Homelab security on 512 MB&lt;/a&gt;. This one is about why it exists at all. I didn't set out to write a security tool. I kept finding gaps in the setup I already had, and warden is what was left after I filled them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The address you want to ban isn't on the wire
&lt;/h2&gt;

&lt;p&gt;My public services sit behind a Cloudflare tunnel. That's good for lots of reasons, but it breaks the usual homelab reflex of "see a bad IP, drop it in iptables".&lt;/p&gt;

&lt;p&gt;Behind the tunnel, the source address at the network layer is cloudflared or the Cloudflare edge. The attacker's real address only exists in the HTTP layer, in a header the proxy passes along. So a local firewall ban keyed on the attacker never matches a single packet. And if you ban what does show up at L3, you've banned Cloudflare and cut off your own estate.&lt;/p&gt;

&lt;p&gt;I wrote that down before writing any code, and it's why warden has no iptables code in it. Any ban has to happen at the Cloudflare edge, before traffic reaches the tunnel. Cloudflare's own edge ranges sit on warden's allowlist from the start, next to my own addresses.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0ltq0drul6fsvn43q8r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0ltq0drul6fsvn43q8r.png" alt="Request path from attacker through the Cloudflare edge, tunnel and reverse proxy to the app. The real client IP only exists in an HTTP header, so a local iptables ban never matches; the ban has to happen at the edge" width="799" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CrowdSec was running. It just couldn't see everything.
&lt;/h2&gt;

&lt;p&gt;I'd had CrowdSec in place since June, sending its local bans to Cloudflare. On paper that was covered. When I looked properly in August, it wasn't.&lt;/p&gt;

&lt;p&gt;The web tier was basically unwatched. My reverse proxy doesn't write the standard nginx log format, so when I mounted its log as nginx, every line landed in "unparsed". That's coverage that looks real and detects nothing. I wrote a custom parser for it, but the bigger hole was the tunnel itself. Some hostnames go straight from the tunnel to a service and skip the proxy completely, and nothing was reading cloudflared's log. That's where I found someone probing one of those direct routes for a Vite file-read bug, against a service that wasn't even running any more.&lt;/p&gt;

&lt;p&gt;So warden started on 30 August as a small, dependency-free second opinion to CrowdSec. It reads the logs CrowdSec can't see or doesn't parse (the proxy log, my SSO's log, and the tunnel log) and scores what it finds. CrowdSec keeps running alongside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it asks before it acts
&lt;/h2&gt;

&lt;p&gt;CrowdSec bans automatically. My sync pushed its local bans to the edge every 60 seconds. That's fine until you look at who is getting flagged.&lt;/p&gt;

&lt;p&gt;The only local CrowdSec alert in one September week was my own WAN IP: the Immich app on my phone asking for 11 thumbnails I'd deleted, all within four seconds. Warden agreed with it: 27 of the 29 app-layer events that week came from that same address. On 7 October, 281 of the 295 edge events I pulled were me as well. On 10 October it was 1,526 of 1,577 events in 24 hours, until I filtered my own rows out of the counts.&lt;/p&gt;

&lt;p&gt;The loudest "attacker" on my network is me. A detector you don't fully trust, pointed at traffic that's mostly yours, shouldn't be holding the ban button.&lt;/p&gt;

&lt;p&gt;I'd also seen what automation does with bad readings. My security daemon once restarted CrowdSec every ten minutes for a day. CrowdSec was never down. An SSH timeout came back empty and got read as "service dead". Warden's own first ingest put weeks of July history inside the live ban window, which in enforcing mode would have banned people for July (the details are in the other post).&lt;/p&gt;

&lt;p&gt;So warden ships detect-only. It records what it would ban and changes nothing. When I want something blocked at the edge, it proposes it in Discord and I tap ✅ or ❌. Approved bans go into one WAF rule and lapse after 30 days. A ❌ is remembered for 90 days so it doesn't keep asking. It refuses to propose my own addresses, private ranges or Cloudflare's. Patching works the same way: it shows me the upgrade plan first, takes a snapshot, and never reboots a Proxmox node on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  One place, on a small box, with nothing installed
&lt;/h2&gt;

&lt;p&gt;The other reason was sprawl. Logs in one tool, CVEs nowhere, patches done by hand, and no real list of what was on my network. When I needed a SIEM layer in September I chose to extend warden instead of deploying Loki or Wazuh.&lt;/p&gt;

&lt;p&gt;Everything it does is agentless. Vulnerability scanning copies the package list off each box and checks it against OSV.dev, the open database that Debian, Ubuntu, Alpine, GitHub and the Go team publish their advisories into. For Docker it reads what's actually in each running container, not just the image it came from. Integrity checks are one read-only shell collector per box. Nothing gets installed on the things being watched. I measured the core at 1 vCPU, 512 MB of RAM and 300 MB of disk.&lt;/p&gt;

&lt;p&gt;It used to run Trivy for the vulnerability side, and that was the weak point: a 161 MB binary, a server process, a 1.4 GB database, and a project whose own release got backdoored this year (CVE-2026-33634). So I swapped it out on 10 October. On my Proxmox node the new scanner found 680 of the 691 CVEs Trivy found, both agreed nothing was fixable, and the odd ones out were the two databases updating at different times. It also binned two "actively exploited" Trivy alerts for a package one container doesn't even run any more.&lt;/p&gt;

&lt;p&gt;None of the pieces are new. Wazuh, CrowdSec, Vuls, Security Onion and others all exist, and warden leans on OSV.dev's data, Lynis and Suricata for the hard parts. What I couldn't find was that combination: agentless, small, and asking in chat before it does anything. It's AGPL-3.0 since 8 October.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents read it, they don't decide
&lt;/h2&gt;

&lt;p&gt;I run Hermes agents at home, and the obvious temptation is to let one of them do the security work. I don't. Warden has a read-only MCP server, and my &lt;code&gt;sec&lt;/code&gt; agent connects to it as a reader: summaries, detections, devices, CVEs. My IT admin agent includes warden's facts in its daily report, and its rules say it reports and never approves. Scripts gather the facts and I make the call. The agent is there to explain things, not to decide them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newest bit: the house itself
&lt;/h2&gt;

&lt;p&gt;The newest gap was the house. Warden knew about servers, but not the bulbs, plugs and TVs. On 10 October it gained a device inventory that merges ARP sweeps with the router's DHCP leases: 107 real devices from 133 addresses, with 5 left as honest unknowns rather than guesses.&lt;/p&gt;

&lt;p&gt;Then I gave each device a DNS baseline from the router's query log. Replaying a week of history gave 1.17 million lookups across 85 devices. Some devices are very predictable. One WiZ bulb has talked only to &lt;code&gt;wiz.world&lt;/code&gt; for nine days. A smart lock bridge talks only to its maker.&lt;/p&gt;

&lt;p&gt;That made scored decisions possible instead of "count over threshold". When a fixed-function device that's past its 7-day learning period hits a domain it has never used, warden starts at 40 points and adjusts. Plus 25 if no other device uses that domain, minus 25 if two or more do, minus 30 if the same kind of device all changed together, minus 20 if the maker's name is in the domain, plus 15 for 00:00 to 05:00, plus 15 for a risky TLD. Under 25 it just learns it, 25 to 54 gets logged, and 55 or more asks me.&lt;/p&gt;

&lt;p&gt;Replaying the real week produced zero reviews. A simulated WiZ bulb calling &lt;code&gt;update-check.xyz&lt;/code&gt; at 3am scored 95 and showed up for review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8w03n4rfh9m5qo73b62x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8w03n4rfh9m5qo73b62x.png" alt="A simulated WiZ bulb looking up update-check.xyz at 3am: base 40, plus 25 because nothing else uses it, plus 15 for the hour, plus 15 for the TLD, 95 in total, well into the review band" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's still limited. Device scoring only sees DNS, my IoT VLAN has no devices on it yet, and the edge ban path is brand new: three bans approved so far, all on 10 October. But each part exists because something I already ran was missing it, not because a feature list said so.&lt;/p&gt;

&lt;p&gt;If you run services behind a Cloudflare tunnel, how do you deal with bans when the address you'd block only exists in a header?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>agents</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Agent-run social media for a one-person Etsy shop: what 205 views taught me</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:06:04 +0000</pubDate>
      <link>https://dev.to/iam-tech/agent-run-social-media-for-a-one-person-etsy-shop-what-205-views-taught-me-182b</link>
      <guid>https://dev.to/iam-tech/agent-run-social-media-for-a-one-person-etsy-shop-what-205-views-taught-me-182b</guid>
      <description>&lt;p&gt;I run two tiny things on my own. THEC1LAB is a print shop on Etsy: posters drawn in code, gold on black. IAMC/TECH is my tech page: homelab field notes, tips, AI tools. So over the last few weeks I've had agents on my homelab run the social side, and I just approve or veto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two brands, kept apart on purpose
&lt;/h2&gt;

&lt;p&gt;Early on I saw one combined intro covering all my pages and it looked wrong. They're different brands with different people buying. So the rule is simple: no Etsy CTAs on @iamc_tech, no homelab tips on the shop pages. Each brand has its own intro, its own colours, its own sign-off.&lt;/p&gt;

&lt;p&gt;It still bit me twice. A planets post on the shop account got tech hashtags. And the tech page's "what's inside" reels wanted to end on a THEC1LAB poster card, which had to be stripped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9jvonj6fuiiucfclzu4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9jvonj6fuiiucfclzu4.png" alt="Two brands kept apart: THEC1LAB's TikTok, Instagram, YouTube Shorts and Pinterest all point at Etsy; IAMC/TECH's Instagram and Threads point at dev.to, Gumroad and GitHub, with a no-crossover line between them" width="799" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Each channel and how it's fed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;How it's fed&lt;/th&gt;
&lt;th&gt;What needs a human&lt;/th&gt;
&lt;th&gt;Result so far&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TikTok @thec1lab&lt;/td&gt;
&lt;td&gt;Daily video rendered from the fact bank, sent as an inbox draft at 17:30&lt;/td&gt;
&lt;td&gt;Me: add a sound, press post&lt;/td&gt;
&lt;td&gt;200 to 740 views per video&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram @thec1lab&lt;/td&gt;
&lt;td&gt;Reels, carousels, stories; preview in Discord about an hour before&lt;/td&gt;
&lt;td&gt;❌ to cancel; pins, bio&lt;/td&gt;
&lt;td&gt;Carousels blocked since 7 Oct; reels still go out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram @iamc_tech&lt;/td&gt;
&lt;td&gt;"Server" reels: jokes, tips, explainers&lt;/td&gt;
&lt;td&gt;❌ to cancel&lt;/td&gt;
&lt;td&gt;Started from 1 follower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube Shorts&lt;/td&gt;
&lt;td&gt;Uploader takes the day's approved video at 19:45&lt;/td&gt;
&lt;td&gt;Nothing after approval&lt;/td&gt;
&lt;td&gt;First Short: 1,019 views, 15 likes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinterest&lt;/td&gt;
&lt;td&gt;Weekly bulk CSV of fresh pins&lt;/td&gt;
&lt;td&gt;Me: upload the CSV&lt;/td&gt;
&lt;td&gt;First shop pin scheduled for 11 Oct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threads @iamc_tech&lt;/td&gt;
&lt;td&gt;One tech news story per slot, plus my own dev.to posts&lt;/td&gt;
&lt;td&gt;❌ to cancel&lt;/td&gt;
&lt;td&gt;Too early to say&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;TikTok.&lt;/strong&gt; My TikTok is a personal account. Switching to business wanted Companies House details and I'm a sole trader. Posting through the API from an unaudited app gets forced to SELF_ONLY, which means only I can see it. Cookie bots were off the table because of shadowban risk. So the agent sends each video as an inbox draft, drops it in Discord at 17:30, and I add a sound and post it myself. Sound matters: early on a video with a commercial sound and a question caption got 389 views, a silent shop drop got 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instagram.&lt;/strong&gt; Posting to my own account through the API is allowed here, so it's fully automated. Every post gets previewed in Discord about an hour before it goes out and I can ❌ it. The agent also re-weights the mix every week from insights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube Shorts.&lt;/strong&gt; I set the channel up on 8 Oct, uploaded one Short, and then nothing. Turns out the daily uploader was never actually built. That first Short got 1,019 views and 15 likes, better than any single TikTok I've done. The uploader went in on 10 Oct: it takes the day's approved video, fixes the loudness, uploads it and checks it really came out public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinterest.&lt;/strong&gt; This one's about links. On 10 Oct my 138 active Etsy listings had 205 views ever and 1 favourite, while TikTok videos do 200 to 740 views each. The problem is the bridge: a personal TikTok gets no clickable bio link until 1,000 followers. Pins are links. So every week a script makes fresh pins (2 a day, one per listing, words only from the listing itself) and writes a bulk CSV. I upload it by hand. Pinterest's API trial access only makes pins visible to you, and I turned down using my own cookie to automate it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threads.&lt;/strong&gt; The tech page's Threads is for quick tech and AI news: one Hacker News story, why it matters, a clickable source link, under 500 characters. Since 8 Oct one slot a day shares my own dev.to posts, because I don't self-promote enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripts gather facts, models never do
&lt;/h2&gt;

&lt;p&gt;This is the rule that holds the whole thing together. The model can write a caption or a joke. It does not get to invent a number.&lt;/p&gt;

&lt;p&gt;So the moon distances, binary puzzles and "which part is this" answers are all computed by code from a fact bank. Tech facts carry a source and a verbatim quote. Then validators check the output: numbers and names only from the cited facts, no hype words, no "closest" or "only" unless the fact text says so. In testing it caught a made-up 360,000 km, a stray London and a model typing "Manches... nah let me try again" into a caption.&lt;/p&gt;

&lt;p&gt;There's also a small decision model that scores captions. Genuine questions came back as supported at 0.81 to 0.95. A made-up claim scored 0.05. "Comment YES and tag 3 friends" scored 0.97 for bait.&lt;/p&gt;

&lt;p&gt;The jokes on the tech page work the same way. The comic writer gives up to six fresh jokes a day, filters throw out anything with digits or a reused punchline, and then the judge asks "would most people smile". I added the judge because the first fresh jokes passed every check and still weren't funny. One example: a foreign-key joke scored 0.81, a flat one 0.13. Anything under 0.6 falls back to a fixed list. Since 10 Oct the shop's talking-Moon banter gets a fresh judged joke every day too, so it doesn't run out.&lt;/p&gt;

&lt;p&gt;And on top of all that, my ✅ and ❌ in Discord. The agents do the day-to-day running. I keep the veto.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wbszrp1u0nvssjmd97j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wbszrp1u0nvssjmd97j.png" alt="Every post goes fact bank, model writes, validator, judge score, Discord preview, platform. Validator failures get one repair round; jokes under 0.6 fall back to a fixed list" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest numbers
&lt;/h2&gt;

&lt;p&gt;As of 10 Oct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Etsy: 138 active listings, 205 views ever, 1 favourite.&lt;/li&gt;
&lt;li&gt;TikTok: 200 to 740 views a video. The anatomy smartphone one did 716.&lt;/li&gt;
&lt;li&gt;YouTube: one Short, 1,019 views, 15 likes. Uploader built 10 Oct.&lt;/li&gt;
&lt;li&gt;Instagram shop account: carousels have failed 6 of 7 times since 7 Oct 16:00 with "Action is blocked". Quota use was 1 out of 100 and reels went straight through, so it's an account-level spam flag, not volume. Reels and stories still publish daily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also, the comment agent has never seen a single comment on either Instagram account. The Meta app is still in Development mode, which only shows comments from people with a role on the app. Strangers are invisible until I switch it to Live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that hid in plain sight
&lt;/h2&gt;

&lt;p&gt;The worst one was audio. The reel mixer was built for silent videos. So it only kept the music, loudness-normalised it to -14 LUFS, and silently dropped the talking-Moon voices. Every banter reel went to Instagram as full-volume music with the characters miming. I only caught it because the music sounded too loud.&lt;/p&gt;

&lt;p&gt;For the tech page it's now voice at -16 LUFS with music at -30 underneath. The shop account kept the old mix for now. On the TikTok side the banter originals still come out at about -36 LUFS, which is very quiet, and that's not fixed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;p&gt;Cookie bots. I tried a headless browser with my own Etsy cookies to send buyer messages. The first page loaded, the second got a 403: "Access is temporarily restricted … automated (bot) activity on your network". That's my home connection, so it could hit my normal browsing too. Stopped there, nothing sent. Same answer for TikTok and Pinterest cookies: not worth the ban.&lt;/p&gt;

&lt;p&gt;Engagement bots. I asked for something to follow and like for traction on Instagram. The answer was no: it breaks Meta's terms, and the API can't do it anyway. Better content and fast replies instead.&lt;/p&gt;

&lt;p&gt;£1 drops. Cheap limited drops on TikTok didn't work, so they've stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The talking-Moon banter shorts are the content model now, and from 16 Oct they start promoting other products in the shop, not just one.&lt;/p&gt;

&lt;p&gt;The real test is Pinterest. The first shop pin was scheduled for 11 Oct, and around 22 Oct I'll check Etsy's traffic sources to see if pins actually send anyone. The evidence for Pinterest driving Etsy printable traffic is anecdotal, so I'm not counting on it.&lt;/p&gt;

&lt;p&gt;If you've run a small shop off a personal TikTok, what actually got people from a video to your listing before you hit 1,000 followers?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>testing</category>
      <category>automation</category>
    </item>
    <item>
      <title>Toolsmith: building a homelab research agent that says 'I don't know'</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:02:00 +0000</pubDate>
      <link>https://dev.to/iam-tech/toolsmith-building-a-homelab-research-agent-that-says-i-dont-know-5a8f</link>
      <guid>https://dev.to/iam-tech/toolsmith-building-a-homelab-research-agent-that-says-i-dont-know-5a8f</guid>
      <description>&lt;h2&gt;
  
  
  The two-year itch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2pi6zle4gzacvnbcw3m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2pi6zle4gzacvnbcw3m.png" alt="Toolsmith in three panels: the model that made things up, the facts-first fix, and the honest result after two weekly runs" width="800" height="748"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every six months or so I stare at my homelab and wonder if the thing doing DNS should be doing SSO, and the thing doing SSO should be doing MFA, and the thing doing backups should be doing... you get the picture. Tool fatigue is real when you're running fifteen services on a low-power box and each one made sense when you installed it but the landscape has shifted twice since then.&lt;/p&gt;

&lt;p&gt;I wanted an agent that would do the research for me. Not a chatbot that vomits a comparison table, but something that comes back every week with a measured brief: here are the three alternatives to your current tool, here's why you'd switch, and here's why you wouldn't.&lt;/p&gt;

&lt;p&gt;I built it. I called it toolsmith. And the most important piece of it is the component that refuses to answer the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What toolsmith actually is
&lt;/h2&gt;

&lt;p&gt;Toolsmith is a weekly research agent that lives in a Docker sandbox and has one job: given a homelab tool you're running, find credible replacements and write up what you'd gain and lose by switching. The &lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;repository lives on GitHub&lt;/a&gt; if you want the full picture.&lt;/p&gt;

&lt;p&gt;The architecture hits three constraints that shaped everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured fact packs.&lt;/strong&gt; Every recommendation must cite where the information comes from. Not a link dropped at the bottom — the claim and the source are paired in the output. If the source says one thing and the marketing page says another, the agent is told to surface the conflict, not pick a side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capped Docker sandbox with no egress by default.&lt;/strong&gt; The agent runs in a container that can't reach the internet unless a specific research phase explicitly opens an outbound channel. That means no "let me just check the latest blog post mid-run" drift. The research window is defined upfront, the sandbox opens, the data comes in, the sandbox closes. It keeps the agent honest about working with what it gathered rather than live-navigating and getting distracted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A validator that withholds unsupported recommendations.&lt;/strong&gt; This is the one I want to talk about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validator
&lt;/h2&gt;

&lt;p&gt;Most agent projects I've built or studied optimise for giving an answer. The agent is embarrassed if it says "I don't know" — so it finds something, anything, to recommend. Maybe it conflates two tools, maybe it extrapolates from a forum post with three upvotes, maybe it just makes something plausible.&lt;/p&gt;

&lt;p&gt;Toolsmith's validator does the opposite. It looks at the proposed recommendation and checks: is there a direct source for every factual claim? If the answer is no for any claim that the recommendation depends on, the validator strips that recommendation from the output. Not marks it as low confidence — withholds it entirely.&lt;/p&gt;

&lt;p&gt;The first time I saw this in action I thought I'd broken something. The agent ran, did its research, produced a perfectly reasonable-looking analysis, and the validator gutted half of it. Two of three tool recommendations vanished. What remained was thin. I nearly patched the validator to be more lenient.&lt;/p&gt;

&lt;p&gt;I'm glad I didn't.&lt;/p&gt;

&lt;p&gt;What remained was &lt;em&gt;correct&lt;/em&gt;. The surviving recommendation cited actual documented feature matrices, a real changelog entry about the breaking change I was trying to avoid, and a comparison that accounted for the specific hardware constraints of my homelab box. The two that got cut were built on forum speculation, a six-month-old Reddit thread that the agent had treated as authoritative, and one that was just plain hallucinated version numbers.&lt;/p&gt;

&lt;p&gt;The commit history shows a few rounds of "validator identifier fixes" — the nitpicky work of getting the validator to recognise source material reliably. It is not a solved problem. But the direction is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;A few things didn't survive contact with reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The knowledge base was only as good as its last sync.&lt;/strong&gt; I integrated a Sanity Context MCP knowledge base to give the agent a curated starting point. Great in theory. In practice, the knowledge base needed prose updates, identifier corrections, and new demo images constantly. If I wasn't tending it weekly, the agent was working from stale ground truth. The knowledge base became a second project to maintain, which defeats the purpose of an agent that's supposed to save you time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sandbox isolation added friction to iteration.&lt;/strong&gt; Every time the research scope changed — "oh, also check if this tool supports LDAP" — I had to redeclare the sandbox permissions. The no-egress-by-default design is principled, but when you're iterating on prompts it feels like the system is working against you. I don't have a clean solution for this. I suspect the answer is better tooling around ephemeral sandbox configuration rather than softening the isolation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Buy Me a Coffee button went up before the thing was useful.&lt;/strong&gt; I added it early, before the validator was tuned, before the knowledge base was populated. Nobody bought me a coffee, which is exactly what should happen when you ask for support on a project that still eats its own homework. The commit that added that link sits there as a reminder to ship value before asking for gratitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that transfers
&lt;/h2&gt;

&lt;p&gt;The takeaway isn't about toolsmith specifically. It's about where to put the defensive boundary in an AI-assisted workflow.&lt;/p&gt;

&lt;p&gt;Every agent project I see optimises for throughput — more completions, faster answers, lower latency, broader knowledge. The implicit assumption is that more information is better and the human will filter out the bad parts. But that's exactly wrong for homelab work. A bad recommendation in production infrastructure doesn't waste an hour, it wastes a weekend of rebuilding something that was working fine.&lt;/p&gt;

&lt;p&gt;The right defensive boundary is the one that says "I have an answer but I cannot vouch for it, so I will not give it to you." Not a confidence score, not a disclaimer paragraph, not a footnote. Withhold.&lt;/p&gt;

&lt;p&gt;That design choice — a validator with teeth — changes the optimisation target of the whole system. The agent stops trying to sound smart and starts trying to be verifiable. The research phase becomes about collecting sources that can survive inspection rather than assembling a plausible narrative. The prompt engineering shifts from "be thorough and helpful" to "be correct or be silent."&lt;/p&gt;

&lt;p&gt;I don't think this applies only to agents. Any automation that makes decisions about infrastructure should have the same bias. A backup script that half-works is worse than one that fails loudly. A migration tool that mostly migrates data is worse than one that refuses to proceed on schema mismatch. Sometimes the most helpful thing a tool can do is tell you it can't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands now
&lt;/h2&gt;

&lt;p&gt;Toolsmith runs weekly in my homelab. Some weeks it produces useful research. Some weeks the validator cuts everything and I get an empty report. Those weeks I curse it, then I go manually look up what I needed, and usually I find that the validator was right — there wasn't a clear enough answer to automate.&lt;/p&gt;

&lt;p&gt;That's fine. An empty report from an honest system is worth more than a confident recommendation from one that doesn't want to disappoint you.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;casareanderson/toolsmith&lt;/a&gt; if you want to see the current state. It's early, it's rough, and the validator eats its own output regularly. But the direction — measured facts, isolated research, and a hard no when the evidence isn't there — is one I think more homelab tools should take.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built for the shelf, not for the shop window.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
