<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>Rahul&#39;s Build Log</title>
  <subtitle>Things I build, break and learn.</subtitle>
  <link href="https://blog.vrp2206.com/feed.xml" rel="self" />
  <link href="https://blog.vrp2206.com/" />
  <updated>2026-10-22T06:01:00Z</updated>
  <id>https://blog.vrp2206.com/</id>
  <author>
    <name>Rahul Vedant Pemsing</name>
  </author>
  <entry>
    <title>My &quot;LAN speed test&quot; was actually an internet speed test</title>
    <link href="https://blog.vrp2206.com/posts/lan-speed-test/" />
    <updated>2026-10-22T06:01:00Z</updated>
    <id>https://blog.vrp2206.com/posts/lan-speed-test/</id>
    <content type="html">&lt;p&gt;I wanted to know how fast my server&#39;s network actually is. The standard tool is &lt;code&gt;iperf3&lt;/code&gt;: run a server on one end and a client on the other, and it measures raw throughput with the disks out of the picture.&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-s&lt;/span&gt;                    &lt;span class=&quot;token comment&quot;&gt;# on the server, listens on port 5201&lt;/span&gt;
iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;server-ip&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;        &lt;span class=&quot;token comment&quot;&gt;# on the client&lt;/span&gt;
iperf3 &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;server-ip&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-R&lt;/span&gt;     &lt;span class=&quot;token comment&quot;&gt;# reverse direction: server sends to the client&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;attempt-1-timed-out&quot;&gt;Attempt 1: timed out&lt;/h2&gt;
&lt;p&gt;I pointed my Windows PC at the server&#39;s wired LAN address and got:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;iperf3: error - unable to connect to server - server may have stopped running
or use a different port, firewall issue, etc.: Connection timed out&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My first thought was the firewall. But &lt;code&gt;ping&lt;/code&gt; to the same address timed out too, so this wasn&#39;t about iperf3 or a port at all. The PC couldn&#39;t reach that address.&lt;/p&gt;
&lt;h2 id=&quot;the-clue-i-d-left-out&quot;&gt;The clue I&#39;d left out&lt;/h2&gt;
&lt;p&gt;Then I mentioned that I was talking to Claude &lt;strong&gt;over SSH from that same PC&lt;/strong&gt;. If SSH works but the LAN address doesn&#39;t, the SSH session must be taking a different path. Claude checked where the session was coming from: a Tailscale address. The PC wasn&#39;t on my home LAN at all. It was reaching the server through the Tailscale tunnel.&lt;/p&gt;
&lt;h2 id=&quot;attempt-2-about-30-mbit-s&quot;&gt;Attempt 2: about 30 Mbit/s&lt;/h2&gt;
&lt;p&gt;Pointed at the server&#39;s Tailscale address, iperf3 connected and reported about &lt;strong&gt;30 Mbit/s&lt;/strong&gt;. That&#39;s terrible for gigabit Ethernet, so I asked whether something was wrong.&lt;/p&gt;
&lt;p&gt;It wasn&#39;t. &lt;code&gt;tailscale status&lt;/code&gt; showed a &lt;strong&gt;direct&lt;/strong&gt; connection (not relayed through a DERP server), with the PC at a public IP on a different network about 10 ms away. So the test measured:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;the slower of the PC&#39;s download speed and the server&#39;s home &lt;strong&gt;upload&lt;/strong&gt; speed, plus WireGuard overhead.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;30 Mbit/s is a normal home upload speed. The number was right. I was just measuring something other than what I thought.&lt;/p&gt;
&lt;h2 id=&quot;checking-the-actual-lan-link&quot;&gt;Checking the actual LAN link&lt;/h2&gt;
&lt;p&gt;To rule the cable out, Claude ran a few checks from the server towards the router:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Link speed:&lt;/strong&gt; &lt;code&gt;ethtool&lt;/code&gt; reported &lt;code&gt;1000Mb/s&lt;/code&gt;, so the Cat6 cable and router port negotiated gigabit. A damaged cable often falls back to 100 Mbit/s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency to the router:&lt;/strong&gt; about 0.8 ms average, with 0% packet loss.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The wired link is healthy. Measuring its real throughput needs a second wired device on the same LAN running &lt;code&gt;iperf3 -s&lt;/code&gt;, since most routers can&#39;t act as an iperf3 endpoint.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A timeout on both &lt;code&gt;ping&lt;/code&gt; and the app means a reachability problem&lt;/strong&gt;, not a port or firewall problem in the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Know which path your traffic takes.&lt;/strong&gt; With Tailscale running, &amp;quot;the server&#39;s address&amp;quot; can mean a LAN IP, a tailnet IP or something routed in between. Check with &lt;code&gt;tailscale status&lt;/code&gt;, and &lt;code&gt;echo $SSH_CLIENT&lt;/code&gt; shows where an SSH session is coming from.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Over a VPN, iperf3 measures the internet.&lt;/strong&gt; Over the internet, home upload speed is almost always the bottleneck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ethtool &amp;lt;iface&amp;gt; | grep Speed&lt;/code&gt; is the 5-second cable check&lt;/strong&gt; before you go buying new cables.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>My new monitoring paged me twice on day one. Good.</title>
    <link href="https://blog.vrp2206.com/posts/homelab-false-alarms/" />
    <updated>2026-10-22T05:45:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-false-alarms/</id>
    <content type="html">&lt;p&gt;The &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-hardening/&quot;&gt;hardening work&lt;/a&gt; added two new scheduled jobs: a 5-minute heartbeat to healthchecks.io and a nightly restic backup. Both had passed their tests. Within 24 hours, &lt;strong&gt;both triggered a Discord alert&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;alert-1-the-heartbeat-failed&quot;&gt;Alert 1: the heartbeat &amp;quot;failed&amp;quot;&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;archlinux: Failed systemd units: server-heartbeat.service&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The server was fine. The journal showed two failed pings to &lt;code&gt;hc-ping.com&lt;/code&gt;: a timeout, and then an &lt;strong&gt;HTTP/2 error&lt;/strong&gt; on the next try. My own connection looked healthy (0% loss to 1.1.1.1 at about 2.5 ms), but the route to the ping service was about 240 ms away and dropping a lot of ICMP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real bug was the design.&lt;/strong&gt; The heartbeat script exited non-zero when a ping failed, so systemd marked the unit failed, and my existing health monitor (which reports failed units to Discord) raised the alarm.&lt;/p&gt;
&lt;p&gt;But a missed ping is &lt;em&gt;already&lt;/em&gt; the signal. healthchecks.io alerts when pings stop arriving, so making the unit fail too just creates a second, noisier alert. The fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ping over &lt;strong&gt;HTTP/1.1&lt;/strong&gt;, which avoids the HTTP/2 failure;&lt;/li&gt;
&lt;li&gt;retry up to 5 times, 3 seconds apart;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;never fail the unit because a ping failed.&lt;/strong&gt; If the pings really stop, healthchecks.io alerts after the 10-minute grace period.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The backup job&#39;s pings got the same treatment.&lt;/p&gt;
&lt;p&gt;The good news: the alert chain worked end to end. A problem was detected and reached my phone within minutes.&lt;/p&gt;
&lt;h2 id=&quot;alert-2-the-backup-failed-at-03-36&quot;&gt;Alert 2: the backup failed at 03:36&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ archlinux: Failed systemd units: server-backup.service&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This one was a genuine failure, and nothing had been backed up that night. restic stopped within a second because it couldn&#39;t find a cache directory. &lt;strong&gt;When systemd runs a service, &lt;code&gt;HOME&lt;/code&gt; isn&#39;t set&lt;/strong&gt;, and restic puts its cache under &lt;code&gt;$HOME/.cache&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The first run had worked because it was started by hand from a shell that &lt;em&gt;has&lt;/em&gt; a home directory. The fix was one line in the unit and one in the script:&lt;/p&gt;
&lt;pre class=&quot;language-ini&quot;&gt;&lt;code class=&quot;language-ini&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# /etc/systemd/system/server-backup.service&lt;/span&gt;
&lt;span class=&quot;token section&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token section-name selector&quot;&gt;Service&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token key attr-name&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token value attr-value&quot;&gt;HOME=/root&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# /usr/local/bin/server-backup&lt;/span&gt;
&lt;span class=&quot;token builtin class-name&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;RESTIC_CACHE_DIR&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;/var/cache/restic&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Running it exactly the way the timer does took about 12 seconds, the repository check came back clean, and the backup check on healthchecks.io went green again.&lt;/p&gt;
&lt;h2 id=&quot;the-lesson-test-the-way-it-will-actually-run&quot;&gt;The lesson: test the way it will actually run&lt;/h2&gt;
&lt;p&gt;Both jobs passed their tests. Both tests ran in a different environment from the real one:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;How it was tested&lt;/th&gt;
&lt;th&gt;How it really runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heartbeat&lt;/td&gt;
&lt;td&gt;One manual ping, on a good connection&lt;/td&gt;
&lt;td&gt;Every 5 minutes, all day, over a flaky route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup&lt;/td&gt;
&lt;td&gt;Started from an interactive shell&lt;/td&gt;
&lt;td&gt;Started by systemd at 03:30, with no &lt;code&gt;HOME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If I&#39;d found the backup bug in month two instead of on day one, I&#39;d have had weeks of &amp;quot;backup failed&amp;quot; alerts to deal with from abroad, or worse, I&#39;d have missed them. Before I leave:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Run every unit with &lt;code&gt;systemctl start&lt;/code&gt;&lt;/strong&gt;, not by running the script by hand. That&#39;s the environment the timer gives it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do a full reboot test&lt;/strong&gt;, so everything starts from cold the way it will after a power cut.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make alerts mean one thing.&lt;/strong&gt; One problem should raise one alert, from the system best placed to notice it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two pages on day one is annoying. Two pages in month two, from another country, would have been much worse.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Getting a homelab ready to run for three months without me</title>
    <link href="https://blog.vrp2206.com/posts/homelab-hardening/" />
    <updated>2026-10-21T08:33:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-hardening/</id>
    <content type="html">&lt;p&gt;I&#39;ll be out of the country for three months with no physical access to the server, and I had about a month to prepare. After the &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-audit/&quot;&gt;audit&lt;/a&gt;, the question became concrete: &lt;strong&gt;what could take this machine down or lock me out while I&#39;m away, and how would I even know?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I started a new Claude Code session on the server and asked it to find every failure point and fix what it could.&lt;/p&gt;
&lt;h2 id=&quot;the-failure-points&quot;&gt;The failure points&lt;/h2&gt;
&lt;p&gt;The first survey sorted the risks into &amp;quot;critical before you leave&amp;quot; and the rest:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The machine could fall asleep.&lt;/strong&gt; It&#39;s a laptop, and closing the lid suspends it by default. A suspended server is a dead server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Power cuts.&lt;/strong&gt; The battery is at about 74% of its design capacity after 1,490 cycles, and there&#39;s no UPS. After an outage, the machine only comes back if the BIOS powers it on when AC returns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One way in.&lt;/strong&gt; One SSH key, and one other device on Tailscale. Lose that laptop and I&#39;m locked out for three months.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups.&lt;/strong&gt; The only copy of everything, including &lt;code&gt;.env&lt;/code&gt; files and tunnel credentials, was on one SSD.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH accepted passwords&lt;/strong&gt; on every interface.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;never-sleep-and-reboot-if-frozen&quot;&gt;Never sleep, and reboot if frozen&lt;/h2&gt;
&lt;p&gt;The easy wins went in first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;logind.conf.d&lt;/code&gt; drop-in so the lid switch, idle and the power key are ignored;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sleep.target&lt;/code&gt;, &lt;code&gt;suspend.target&lt;/code&gt; and &lt;code&gt;hibernate.target&lt;/code&gt; masked, so nothing can suspend it by accident;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;hardware watchdog&lt;/strong&gt; that reboots the machine if it freezes for 30 seconds;&lt;/li&gt;
&lt;li&gt;a full system upgrade (including Tailscale), the journal capped at 500 MB and 3 months, and 2 GB of Docker build cache cleared;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/boot&lt;/code&gt; permissions tightened, so the random seed is no longer world-readable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;ssh-keys-only-verified-before-switching&quot;&gt;SSH: keys only, verified before switching&lt;/h2&gt;
&lt;p&gt;My past logins had used a password, so turning passwords off blind could have locked me out. The order mattered:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I tested a key-only login from my laptop: &lt;code&gt;ssh -o PasswordAuthentication=no ...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Only after that worked did &lt;code&gt;PasswordAuthentication no&lt;/code&gt; go in, along with no root login, &lt;code&gt;AllowUsers&lt;/code&gt;, 3 attempts maximum and a short login grace time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fail2ban&lt;/strong&gt; watches sshd, with my own networks on the ignore list so I can&#39;t ban myself.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;a-second-way-in&quot;&gt;A second way in&lt;/h2&gt;
&lt;p&gt;A second device on Tailscale and a second SSH key, on different hardware:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I added my phone to the tailnet.&lt;/li&gt;
&lt;li&gt;I generated an ed25519 key in Termux. My first try failed because I typed &lt;code&gt;ssh keygen&lt;/code&gt; instead of &lt;code&gt;ssh-keygen&lt;/code&gt;, and ssh tried to connect to a host called &amp;quot;keygen&amp;quot;.&lt;/li&gt;
&lt;li&gt;Claude added the public key, I logged in from the phone &lt;strong&gt;on mobile data with Wi-Fi off&lt;/strong&gt;, and the SSH log confirmed it was the phone&#39;s key that got in.&lt;/li&gt;
&lt;li&gt;I turned off &lt;strong&gt;Tailscale key expiry&lt;/strong&gt; for the server, which otherwise drops off the tailnet after about 180 days.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;wiping-the-hdd-storage-and-backups&quot;&gt;Wiping the HDD: storage and backups&lt;/h2&gt;
&lt;p&gt;The 1 TB HDD only held old Windows data, so I asked for it to be wiped. Before touching it, Claude checked the drive&#39;s &lt;strong&gt;serial number&lt;/strong&gt; matched the HDD, not the NVMe. That&#39;s a cheap check against the most expensive possible mistake.&lt;/p&gt;
&lt;p&gt;It&#39;s now two ext4 partitions, both mounted with &lt;code&gt;nofail&lt;/code&gt; so a dead drive can&#39;t stop the boot:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mount&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mnt/storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;462 GB&lt;/td&gt;
&lt;td&gt;A network storage project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mnt/backup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;454 GB&lt;/td&gt;
&lt;td&gt;Backups, root-only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&quot;backups-that-are-tested-not-assumed&quot;&gt;Backups that are tested, not assumed&lt;/h2&gt;
&lt;p&gt;The backups use &lt;strong&gt;restic&lt;/strong&gt;: a nightly systemd timer backs up &lt;code&gt;/home&lt;/code&gt; and &lt;code&gt;/etc&lt;/code&gt; to &lt;code&gt;/mnt/backup&lt;/code&gt;, keeps 7 daily, 4 weekly and 6 monthly snapshots, and runs &lt;code&gt;restic check&lt;/code&gt; afterwards.&lt;/p&gt;
&lt;p&gt;The first run used 35 GB. The same job then &lt;strong&gt;restored a file from the snapshot and compared it with the original&lt;/strong&gt;, and they matched. A backup you&#39;ve never restored from is only a hope.&lt;/p&gt;
&lt;p&gt;The weak spot is clear: backups on the same machine as the data don&#39;t survive theft, fire or a power surge. An offsite copy is still on the list.&lt;/p&gt;
&lt;h2 id=&quot;docker-and-the-demo-sites&quot;&gt;Docker and the demo sites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The two app ports that were published on every interface are now bound to &lt;code&gt;127.0.0.1&lt;/code&gt;. The tunnel and Caddy reach them locally.&lt;/li&gt;
&lt;li&gt;Container logs rotate at 10 MB × 3 files.&lt;/li&gt;
&lt;li&gt;Fresh Caddy and cloudflared images, all containers recreated, and all three sites returned 200 afterwards.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;trying-network-storage-then-turning-it-off&quot;&gt;Trying network storage, then turning it off&lt;/h2&gt;
&lt;p&gt;I tried a Samba share for &lt;code&gt;/mnt/storage&lt;/code&gt;. It was set up as tightly as I could think of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SMB3 with &lt;strong&gt;encryption required&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;no guest access;&lt;/li&gt;
&lt;li&gt;bound to the Tailscale address only, with an nftables rule dropping SMB from anything else;&lt;/li&gt;
&lt;li&gt;a dedicated Samba password.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Windows rejected the first password (the fix was a simpler password and the username written as &lt;code&gt;server&#92;user&lt;/code&gt;), but after that it worked. Then I asked for it to be turned off again. I want to understand the trade-offs before leaving a file share running for three months. It&#39;s disabled with the config kept, so turning it back on is one command.&lt;/p&gt;
&lt;h2 id=&quot;an-alarm-that-works-when-the-server-doesnt&quot;&gt;An alarm that works when the server doesn&#39;t&lt;/h2&gt;
&lt;p&gt;The server already sends health reports to Discord, but &lt;strong&gt;if it dies, it goes quiet&lt;/strong&gt;, and silence looks exactly like &amp;quot;everything&#39;s fine&amp;quot;. So we added a dead-man&#39;s switch with &lt;a href=&quot;https://healthchecks.io&quot;&gt;healthchecks.io&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Heartbeat:&lt;/strong&gt; every 5 minutes, a script checks that all mounts are present, all containers are running, the sites respond on localhost, there are no failed units, the disk is under 90% and Tailscale is up. If all of that holds it pings healthchecks.io. If not, it pings the &lt;code&gt;/fail&lt;/code&gt; URL with the list of problems. If the server is dead, the pings stop, and that&#39;s the alert.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backups:&lt;/strong&gt; the nightly job reports start, success and failure to a second check.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerts&lt;/strong&gt; go to email, WhatsApp and Discord.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We tested the failure path for real: stopping a container produced &amp;quot;only 3/4 containers running&amp;quot; within one run.&lt;/p&gt;
&lt;h2 id=&quot;still-on-the-list&quot;&gt;Still on the list&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Checking the BIOS for a &amp;quot;power on after AC loss&amp;quot; option. Lenovo consumer laptops rarely have one, so the fallback is a trusted person who can press the power button.&lt;/li&gt;
&lt;li&gt;A low-battery clean shutdown, to stop adding to that unsafe-shutdown count.&lt;/li&gt;
&lt;li&gt;A full &lt;strong&gt;reboot test&lt;/strong&gt;, so every timer and service gets exercised from a cold start.&lt;/li&gt;
&lt;li&gt;An offsite backup copy.&lt;/li&gt;
&lt;li&gt;Rotating the credentials that passed through chat while we were moving fast.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reboot test turned out to matter more than I thought. Within a day, two of the new jobs failed for real. That&#39;s &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-false-alarms/&quot;&gt;part 3&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ExamX, part 2: cloud models, a public site, and pressing pause</title>
    <link href="https://blog.vrp2206.com/posts/examx-part-2/" />
    <updated>2026-10-21T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/examx-part-2/</id>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;Part 1&lt;/a&gt; ended with a working generator and grader, held back by a local model that was too small to write good questions. Around midnight I asked the obvious question: &lt;strong&gt;would a free cloud model fix it?&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;would-gemini-solve-the-problem&quot;&gt;Would Gemini solve the problem?&lt;/h2&gt;
&lt;p&gt;Mostly, yes, with a few catches that were worth spelling out before building anything:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It&#39;s no longer local.&lt;/strong&gt; That was fine: I dropped the &amp;quot;runs locally&amp;quot; requirement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The free tier may use your prompts for training.&lt;/strong&gt; That would include past-paper text and my answers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs an API key,&lt;/strong&gt; which has to live in the environment and never in the repo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The exact checkers stay in charge&lt;/strong&gt; of anything they can check. A better model makes fewer mistakes, but not zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;three-back-ends-and-a-model-switcher&quot;&gt;Three back ends and a model switcher&lt;/h2&gt;
&lt;p&gt;The LLM client got three interchangeable back ends, chosen with a &lt;code&gt;provider:model&lt;/code&gt; spec:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; qwen3:8b                  &lt;span class=&quot;token comment&quot;&gt;# local, via Ollama&lt;/span&gt;
examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; gemini                    &lt;span class=&quot;token comment&quot;&gt;# Google&#39;s default model&lt;/span&gt;
examx generate &lt;span class=&quot;token parameter variable&quot;&gt;--model&lt;/span&gt; claude:claude-sonnet-5-5  &lt;span class=&quot;token comment&quot;&gt;# Anthropic&lt;/span&gt;
examx models                                     &lt;span class=&quot;token comment&quot;&gt;# which back ends are ready&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The web UI got a matching &lt;strong&gt;AI model&lt;/strong&gt; page. It shows whether each provider is ready and how to set it up if not, and a chip in the header always shows the current model. The UI also gained dark mode, a phone layout, past-paper diagrams inside questions, and answers saved in the browser as you type.&lt;/p&gt;
&lt;h2 id=&quot;the-free-tier-is-20-requests-a-day&quot;&gt;The free tier is 20 requests a day&lt;/h2&gt;
&lt;p&gt;I set up a Gemini key (in a file only my user can read, loaded by &lt;code&gt;~/.bashrc&lt;/code&gt;, nowhere near the repo). The first surprise: &lt;code&gt;gemini-2.5-flash&lt;/code&gt; was no longer offered to new keys, so the default moved to &lt;code&gt;gemini-3.5-flash&lt;/code&gt;. It answered a test question about decidability correctly in under 4 seconds.&lt;/p&gt;
&lt;p&gt;Then a full paper ran for 22 minutes and produced almost nothing from Gemini:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the free tier allows &lt;strong&gt;20 requests per day&lt;/strong&gt; for that model, and one AI-written paper needs &lt;strong&gt;15–25&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;several calls failed earlier anyway with &amp;quot;model experiencing high demand&amp;quot;;&lt;/li&gt;
&lt;li&gt;my retry logic waited and retried for minutes, even after the daily quota was gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fix was to treat &amp;quot;quota used up for hours&amp;quot; differently from &amp;quot;slow down for a minute&amp;quot;. ExamX now stops at once, skips that model for the rest of the run, and says when the quota resets. In practice the free tier gives you about &lt;strong&gt;one AI-written paper or one marked attempt a day&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;putting-it-online&quot;&gt;Putting it online&lt;/h2&gt;
&lt;p&gt;I decided to try Claude Sonnet instead and asked for the app at &lt;code&gt;exam-x.vrp2206.com&lt;/code&gt;. The server already ran Caddy and a Cloudflare Tunnel for my hackathon demo sites, so ExamX went in as a Docker container behind its own tunnel.&lt;/p&gt;
&lt;p&gt;Two design decisions from this part are worth keeping:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;API keys live in the visitor&#39;s browser, not on the server.&lt;/strong&gt; You paste your key into the AI model page. It&#39;s stored as an HttpOnly cookie, checked once against the provider&#39;s free model-list endpoint, used only while your job runs, and never written to disk or logged. By default it&#39;s forgotten when you close the browser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; The app first refused every request that hadn&#39;t passed Cloudflare Access.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cloudflare Access then asked for a credit card, so I asked to drop it. Before switching the guard off, Claude added the protections a public site needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;visitors can only use &lt;strong&gt;their own&lt;/strong&gt; Claude or Gemini key, never my local GPU models or a server-side key;&lt;/li&gt;
&lt;li&gt;each browser gets an anonymous ID and only sees &lt;strong&gt;its own&lt;/strong&gt; papers and reports;&lt;/li&gt;
&lt;li&gt;the model choice is per browser, and each visitor has daily job limits;&lt;/li&gt;
&lt;li&gt;the owner still has full access from the server itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pressing-pause&quot;&gt;Pressing pause&lt;/h2&gt;
&lt;p&gt;That&#39;s where I stopped. The Claude API is billed separately from a Claude Pro subscription. The free Gemini tier is too small to rely on. The local models are too weak to write new questions. And the real goal was passing the exam, not shipping a product the night before.&lt;/p&gt;
&lt;p&gt;So ExamX was shut down cleanly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;both containers (the app and its tunnel) removed, and the Caddy route removed and reloaded, with the other sites checked afterwards;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;&amp;quot;Project status: paused&amp;quot;&lt;/strong&gt; section at the top of the README, covering what&#39;s done, what I learned and the next steps;&lt;/li&gt;
&lt;li&gt;the code tagged &lt;code&gt;v0.1-paused&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I kept the Ollama models (they&#39;re useful for other ML work) and left the GitHub repo public rather than archived, in case someone else wants to pick it up.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Let code mark what code can mark.&lt;/strong&gt; The exact checkers were the most reliable part of the whole project, and they need no model at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small local models are good at following a template, not at inventing correct maths.&lt;/strong&gt; Using real past questions with official solutions beat every LLM-written alternative I tried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free tiers have shapes.&lt;/strong&gt; &amp;quot;20 requests a day&amp;quot; changes the whole design of a tool that needs 25 requests per run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#39;t paste secrets into a chat.&lt;/strong&gt; I pasted a tunnel token into the conversation to save time, which meant treating it as leaked and replacing it. The final version of the app asks for keys through a form instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stopping is a valid ending.&lt;/strong&gt; The tool still works offline for practice: &lt;code&gt;examx generate --no-llm&lt;/code&gt; and exact marking, no AI needed.&lt;/li&gt;
&lt;/ul&gt;
</content>
  </entry>
  <entry>
    <title>Auditing a gaming laptop that thinks it&#39;s a server</title>
    <link href="https://blog.vrp2206.com/posts/homelab-audit/" />
    <updated>2026-10-20T14:21:00Z</updated>
    <id>https://blog.vrp2206.com/posts/homelab-audit/</id>
    <content type="html">&lt;p&gt;My homelab server is a Lenovo Legion gaming laptop running Arch Linux. It hosts my hackathon demo sites (kept online for my CV), Docker, Tailscale and a Cloudflare Tunnel. It had been running for a while without anyone looking at it critically, so I asked Claude Code to &lt;strong&gt;audit the quality of the system and how it was set up&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-ground-rules&quot;&gt;The ground rules&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read-only first.&lt;/strong&gt; The first pass ran without root.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&#39;t touch the other Claude.&lt;/strong&gt; Another Claude Code session was busy building &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-1/&quot;&gt;ExamX&lt;/a&gt; in a tmux window on the same machine. The audit stayed away from that directory and that session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Root without sharing the password.&lt;/strong&gt; A &lt;code&gt;!&lt;/code&gt; command in Claude Code has no terminal, so &lt;code&gt;sudo&lt;/code&gt; can&#39;t prompt there. Instead of passing my password into the session, Claude wrote a &lt;strong&gt;read-only audit script&lt;/strong&gt;. I read it, ran it with &lt;code&gt;sudo&lt;/code&gt; in my own terminal, and Claude read the output file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last pattern is worth reusing: the AI writes the script, you review and run it, and it reads the results. No credentials ever enter the chat.&lt;/p&gt;
&lt;h2 id=&quot;what-was-already-fine&quot;&gt;What was already fine&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No failed systemd units, and the system was up to date.&lt;/li&gt;
&lt;li&gt;Only 59 explicitly installed packages. Package signature checking was on.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sudo&lt;/code&gt; requires a password (no &lt;code&gt;NOPASSWD&lt;/code&gt; anywhere).&lt;/li&gt;
&lt;li&gt;Every SSH login in the journal was key-based, from my own laptop over Tailscale.&lt;/li&gt;
&lt;li&gt;Both drives passed SMART. The HDD had 0 reallocated or pending sectors, and the NVMe had 0 media errors.&lt;/li&gt;
&lt;li&gt;No port forwarding: remote access goes through Tailscale and a Cloudflare Tunnel.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-wasn-t-worst-first&quot;&gt;What wasn&#39;t, worst first&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No host firewall at all.&lt;/strong&gt; The &lt;code&gt;INPUT&lt;/code&gt; policy was &lt;code&gt;ACCEPT&lt;/code&gt; for IPv4 and IPv6. SSH, HTTP, the tunnel&#39;s metrics port and two Docker-published app ports were listening on every interface. On a machine that has been moved between networks, that matters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH accepted passwords.&lt;/strong&gt; &lt;code&gt;sshd -T&lt;/code&gt; confirmed &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;. I only ever use keys, so turning passwords off costs nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/boot&lt;/code&gt; was world-readable&lt;/strong&gt;, including the random seed file, which systemd flags as a security hole.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;86 unsafe shutdowns on the NVMe&lt;/strong&gt;, and 17% wear after only about 3,000 power-on hours. The last boot had ended with &lt;code&gt;watchdog did not stop!&lt;/code&gt;. That&#39;s what a laptop that sometimes gets hard-powered-off looks like.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups.&lt;/strong&gt; Nothing was configured, &lt;code&gt;/home&lt;/code&gt; lived on a single drive, and the 1 TB HDD held old Windows data, mounted read-only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smaller items:&lt;/strong&gt; membership of the &lt;code&gt;docker&lt;/code&gt; group (root-equivalent), unpinned &lt;code&gt;:latest&lt;/code&gt; images, 589 MB of reclaimable build cache, orphaned packages and a BIOS from 2022.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There was also one thing the audit couldn&#39;t fully explain: &lt;code&gt;/&lt;/code&gt; was 20 GB used of 32 GB, but Docker only accounted for 0.4 GB of it.&lt;/p&gt;
&lt;h2 id=&quot;the-output-a-plan-not-changes&quot;&gt;The output: a plan, not changes&lt;/h2&gt;
&lt;p&gt;The result wasn&#39;t a pile of edits. It was a single Markdown file:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a snapshot of the system and what&#39;s already good;&lt;/li&gt;
&lt;li&gt;an 11-item TODO list in priority order;&lt;/li&gt;
&lt;li&gt;copy-pasteable fixes, each with a rollback note;&lt;/li&gt;
&lt;li&gt;a suggested order of work and the open questions for me to decide.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I sent it to my Windows laptop with Taildrop (&lt;code&gt;tailscale file cp&lt;/code&gt;). Taildrop needs root, or a one-time &lt;code&gt;tailscale set --operator=&amp;lt;user&amp;gt;&lt;/code&gt;. Claude wouldn&#39;t change my Tailscale settings without asking, so I ran the send myself.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Nothing was changed in this session. The point of an audit is to know what you&#39;re dealing with before you touch it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The next day this audit turned into a much bigger job. I&#39;ll be out of the country for three months with no physical access to the box. &lt;a href=&quot;https://blog.vrp2206.com/posts/homelab-hardening/&quot;&gt;Part 2&lt;/a&gt; is about getting it ready for that.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>ExamX, part 1: a practice-exam generator that marks its own papers</title>
    <link href="https://blog.vrp2206.com/posts/examx-part-1/" />
    <updated>2026-10-20T13:35:00Z</updated>
    <id>https://blog.vrp2206.com/posts/examx-part-1/</id>
    <content type="html">&lt;p&gt;My FIT2014 exam (Theory of Computation at Monash) was coming up, and I had five past papers but only two sets of solutions. I wanted more practice than that, so I wrote a brief for a system and handed it to &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt;, running on my homelab server: build a tool that &lt;strong&gt;generates new practice papers with worked solutions and marks my attempts&lt;/strong&gt;, using only free tools and a local model.&lt;/p&gt;
&lt;p&gt;This post covers that first evening. &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-2/&quot;&gt;Part 2&lt;/a&gt; covers cloud models, putting it online, and why I paused it.&lt;/p&gt;
&lt;h2 id=&quot;the-brief&quot;&gt;The brief&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data:&lt;/strong&gt; the 2013 sample exam plus the 2013, 2014, 2015 and 2017 papers. Only two of them came with solutions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hardware:&lt;/strong&gt; the server has 8 CPU cores, 31 GB of RAM and a GTX 1650 with 4 GB of VRAM. That&#39;s a small budget for running an LLM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rules:&lt;/strong&gt; free tools only, a Python virtual environment inside the repo, small commits pushed often, and no credentials stored anywhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;step-1-getting-the-papers-in&quot;&gt;Step 1: getting the papers in&lt;/h2&gt;
&lt;p&gt;All the PDFs had real text in them, so no OCR was needed. Ingestion used PyMuPDF to split each paper into questions and parts, tag each one with a topic, and match it to its official solution where one existed.&lt;/p&gt;
&lt;p&gt;One check made me trust the parser: &lt;strong&gt;every paper&#39;s marks added up to exactly 120&lt;/strong&gt;. That caught several splitting bugs early.&lt;/p&gt;
&lt;p&gt;Two details mattered more than I expected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Superscripts.&lt;/strong&gt; Expressions like a&lt;sup&gt;m&lt;/sup&gt;b&lt;sup&gt;n&lt;/sup&gt; lose their meaning if the exponent becomes plain text. The parser reads font sizes and positions so they survive as &lt;code&gt;a^{m}b^{n}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Colour.&lt;/strong&gt; In the solution PDFs, the answers and the marker&#39;s comments are in different colours, so the parser separates them by colour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result was 101 structured questions in JSON, with the diagrams saved as images.&lt;/p&gt;
&lt;h2 id=&quot;step-2-let-code-mark-what-code-can-mark&quot;&gt;Step 2: let code mark what code can mark&lt;/h2&gt;
&lt;p&gt;Much of FIT2014 has answers you can check exactly. Two regular expressions either describe the same language or they don&#39;t, and a Turing machine either computes the right output or it doesn&#39;t. So before any LLM was involved, the project got a set of &lt;strong&gt;exact checkers&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checker&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex / automata&lt;/td&gt;
&lt;td&gt;Language equivalence, with the shortest string where two answers differ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subset construction and minimisation&lt;/td&gt;
&lt;td&gt;Whether an NFA→DFA conversion or a minimised DFA is correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context-free grammars&lt;/td&gt;
&lt;td&gt;Membership (Earley parsing), derivations, bounded language comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turing machines and PDAs&lt;/td&gt;
&lt;td&gt;Simulation on test inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Propositional logic&lt;/td&gt;
&lt;td&gt;Truth tables and CNF&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Because the checkers name a concrete counterexample, the feedback is specific: &amp;quot;your regex accepts &lt;code&gt;abba&lt;/code&gt;, the language doesn&#39;t&amp;quot;.&lt;/p&gt;
&lt;p&gt;One bug was a good lesson. Some reference Turing machines kept crashing off the left end of the tape. FIT2014 uses one-way infinite tapes, so a machine can&#39;t detect the left end unless it marks it first. The fix belonged in the machines, not in the simulator.&lt;/p&gt;
&lt;h2 id=&quot;step-3-generating-new-papers&quot;&gt;Step 3: generating new papers&lt;/h2&gt;
&lt;p&gt;The generator samples a blueprint from the past papers (which topics, how many marks) and fills each slot in one of two ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Procedural questions.&lt;/strong&gt; Code generates the automata, grammar, logic and decidability questions, so their solutions are correct by construction. Each model solution is fed back through the grader and must score full marks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM questions.&lt;/strong&gt; For proofs and explanations, a local model writes new questions using past-paper questions as examples.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Papers come out as Markdown, PDF and JSON. The PDF path is Markdown → HTML → PyMuPDF, so no LaTeX install was needed.&lt;/p&gt;
&lt;h2 id=&quot;where-the-local-model-fell-over&quot;&gt;Where the local model fell over&lt;/h2&gt;
&lt;p&gt;I benchmarked the Qwen3 models through Ollama. The 8B model was the largest that ran at a usable speed (about 8 tokens/s, split between the 4 GB GPU and system RAM), and the only one that wrote a correct proof in the benchmark.&lt;/p&gt;
&lt;p&gt;A full paper took &lt;strong&gt;19 minutes, and about half of the LLM-written questions were poor&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;near-duplicate questions;&lt;/li&gt;
&lt;li&gt;a &amp;quot;prove that…&amp;quot; of a statement that is &lt;strong&gt;false&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;wrong answers on decidability;&lt;/li&gt;
&lt;li&gt;an NP-completeness question that didn&#39;t make sense.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So we added quality controls: near-duplicate drafts got rejected, a self-review pass checked each question, and LLM-written questions got flagged in the solutions. The second run took 39 minutes and was &lt;strong&gt;worse&lt;/strong&gt;. The self-review changed a correct answer (&amp;quot;is this grammar property decidable? No&amp;quot;) into a wrong one. Five questions fixated on the same grammar, and two of them asked students to prove something it doesn&#39;t generate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;An 8B model on a 4 GB card can&#39;t be trusted to invent exam questions and their solutions. The checkers can&#39;t save a question that&#39;s wrong to begin with.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fix was to give the LLM less to do.&lt;/strong&gt; Decidability tables and language-class questions moved to code. Proof and explanation slots now use &lt;strong&gt;real past questions with their official solutions&lt;/strong&gt; by default. LLM-written questions became an opt-in &lt;code&gt;--prose llm&lt;/code&gt; experiment.&lt;/p&gt;
&lt;h2 id=&quot;marking-proofs&quot;&gt;Marking proofs&lt;/h2&gt;
&lt;p&gt;Proofs can&#39;t be checked by code, so an LLM marks them against a rubric. The 8B model gave a correct proof 1–3 out of 7 and claimed a true step was false. Moving proof marking to &lt;strong&gt;qwen3:14b&lt;/strong&gt; fixed most of that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the correct proof got 5/7, with accurate feedback;&lt;/li&gt;
&lt;li&gt;a deliberately bogus proof got 0/7, so the 14B model wasn&#39;t just being lenient;&lt;/li&gt;
&lt;li&gt;each proof took about 2 minutes to mark.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Proof marks are still labelled advisory in the report.&lt;/p&gt;
&lt;h2 id=&quot;where-it-ended-up-after-one-evening&quot;&gt;Where it ended up after one evening&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A CLI (&lt;code&gt;ingest&lt;/code&gt;, &lt;code&gt;generate&lt;/code&gt;, &lt;code&gt;grade&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;serve&lt;/code&gt; and more) and a small web UI built on Python&#39;s standard-library HTTP server.&lt;/li&gt;
&lt;li&gt;108 passing tests, many built from real past-paper answers.&lt;/li&gt;
&lt;li&gt;An example paper, a deliberately mixed sample attempt, and its marking report in the repo.&lt;/li&gt;
&lt;li&gt;A README that records both LLM runs and their failures as well as the successes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;examx generate --no-llm&lt;/code&gt; makes a fresh paper in about a second, and the exact checkers mark it instantly. That alone was worth building. The open question was whether a better model would fix the question writing, which is where &lt;a href=&quot;https://blog.vrp2206.com/posts/examx-part-2/&quot;&gt;part 2&lt;/a&gt; picks up.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Hello, world (again)</title>
    <link href="https://blog.vrp2206.com/posts/hello-world/" />
    <updated>2026-10-19T00:00:00Z</updated>
    <id>https://blog.vrp2206.com/posts/hello-world/</id>
    <content type="html">&lt;p&gt;My &lt;a href=&quot;https://www.vrp2206.com/&quot;&gt;portfolio&lt;/a&gt; shows the finished projects. This blog is for everything around them: the half-working prototypes, the hackathon weekends, the homelab rabbit holes, and the bits of CS that finally clicked.&lt;/p&gt;
&lt;h2 id=&quot;what-i-ll-post-here&quot;&gt;What I&#39;ll post here&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Project write-ups&lt;/strong&gt;: how something was built, what went wrong, and what I&#39;d do differently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hackathon recaps&lt;/strong&gt;: the idea, the 3am pivots, and the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HomeLab notes&lt;/strong&gt;: configs, dashboards and the occasional broken server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Things I&#39;m learning&lt;/strong&gt;: algorithms, AI tooling, cloud, whatever I&#39;m deep in that month.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;Writing it down is the fastest way to find out whether I actually understand it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;More soon.&lt;/p&gt;
</content>
  </entry>
</feed>