<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet href="https://brandonrozek.com/css/pretty-feed-v3.xsl" type="text/xsl"?>
<rss version="2.0"
    xmlns:atom="http://www.w3.org/2005/Atom"
    xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Brandon Rozek</title>
    <link>https://brandonrozek.com/</link>
    <image>
      <title>Brandon Rozek</title>
      <link>https://brandonrozek.com/</link>
      <url>https://brandonrozek.com/img/avatar.jpg</url>
    </image>
    <description>Software Developer, Researcher, and Linux Enthusiast.</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <managingEditor>brozek@brandonrozek.com (Brandon Rozek)</managingEditor>
    <webMaster>brozek@brandonrozek.com (Brandon Rozek)</webMaster>
    
	<atom:link href="https://brandonrozek.com/index.xml" rel="self" type="application/rss+xml" />
    
  
  <item>
  <title>Postroll: Flight of Locks</title>
  <link>https://katydecorah.com/adventures/flight-of-locks/</link>
  <pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
  <author>Katy Decorah</author>
  <guid>https://katydecorah.com/adventures/flight-of-locks/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Kayaking through the Erie canal locks is so cool! I used to love visiting lock E-2 which is right by Peebles Island State Park.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Kayaking through the Erie canal locks is so cool! I used to love visiting lock E-2 which is right by Peebles Island State Park.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: How to actually handle database transactions (and why your ORM fails at it)</title>
  <link>https://karboosx.net/post/8QW2db7F/how-to-actually-handle-database-transactions-and-why-your-orm-fails-at-it</link>
  <pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
  <author>Karboosx</author>
  <guid>https://karboosx.net/post/8QW2db7F/how-to-actually-handle-database-transactions-and-why-your-orm-fails-at-it</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I don&rsquo;t professionally work with databases, though it was interesting to read about how concurrency issues plague that world as well. The trick of always processing rows in the same order is neat!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I don&rsquo;t professionally work with databases, though it was interesting to read about how concurrency issues plague that world as well. The trick of always processing rows in the same order is neat!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Engineers should start making art</title>
  <link>https://karboosx.net/post/tKnKyfzg/engineers-should-start-making-art</link>
  <pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate>
  <author>Karboosx</author>
  <guid>https://karboosx.net/post/tKnKyfzg/engineers-should-start-making-art</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I don&rsquo;t make art often, but it&rsquo;s cool that the tactic I use for getting something on the page goes by the name <a href="https://en.wikipedia.org/wiki/Surrealist_automatism">automatic drawing</a>.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I don&rsquo;t make art often, but it&rsquo;s cool that the tactic I use for getting something on the page goes by the name <a href="https://en.wikipedia.org/wiki/Surrealist_automatism">automatic drawing</a>.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Randomised Profile Pictures</title>
  <link>https://niqwithq.com/posts/randomised-profile-pictures</link>
  <pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate>
  <author>Niq Bernadowitsch</author>
  <guid>https://niqwithq.com/posts/randomised-profile-pictures</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Fun!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Fun!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: I&#39;m Frustrated about my Inability to Advise Homelab Newcomers</title>
  <link>https://blog.mei-home.net/posts/homelab-newcomer-frustration/</link>
  <pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate>
  <author>Michael</author>
  <guid>https://blog.mei-home.net/posts/homelab-newcomer-frustration/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I can totally relate. I say that the frustration for me extends to tech in general. I haven&rsquo;t used a Windows computer in a while, so I can&rsquo;t easily help someone with issues there. Also, I spin up <a href="https://brandonrozek.com/blog/implementing-cdn-geodns/">multiple servers</a> to run this website when people can instead use <a href="https://bearblog.dev/">Bear blog</a> or <a href="https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site">GitHub pages</a>.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I can totally relate. I say that the frustration for me extends to tech in general. I haven&rsquo;t used a Windows computer in a while, so I can&rsquo;t easily help someone with issues there. Also, I spin up <a href="https://brandonrozek.com/blog/implementing-cdn-geodns/">multiple servers</a> to run this website when people can instead use <a href="https://bearblog.dev/">Bear blog</a> or <a href="https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site">GitHub pages</a>.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>On Post-Quantum Security Adoption</title>
  <link>https://brandonrozek.com/blog/post-quantum-security-adoption/</link>
  <pubDate>Mon, 15 Jun 2026 12:44:12 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/post-quantum-security-adoption/</guid>
  <description><![CDATA[<p>From Alex&rsquo;s <a href="https://alexwlchan.net/2026/post-quantum-blog/">blog post</a>, I&rsquo;ve learned that there are enough recent breakthroughs in quantum computing that I should take post-quantum cryptography seriously. <a href="https://blog.google/innovation-and-ai/technology/safety-security/cryptography-migration-timeline/">Google</a> and <a href="https://blog.cloudflare.com/post-quantum-roadmap/">Cloudflare</a> both set a target of 2029 for having their systems secure against quantum computers. Similarly, the <a href="https://www.ncsc.gov.uk/guidance/pqc-migration-timelines">UK government</a> is targeting 2035.</p>
<p>The issue is that cryptography is built upon math problems that are difficult to solve. Quantum computers make solving some of these problems such as integer factorization and discrete logs easier. If someone has a quantum computer that can sufficiently solve those two problems, then they can likely decrypt many ciphertexts that were produced using <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">asymmetric cryptography</a> techniques (think public/private key-pairs). Wikipedia has a great article discussing <a href="https://en.wikipedia.org/wiki/Post-quantum_cryptography">post-quantum cryptography</a> if you want to read more.</p>]]></description>
  <content:encoded><![CDATA[<p>From Alex&rsquo;s <a href="https://alexwlchan.net/2026/post-quantum-blog/">blog post</a>, I&rsquo;ve learned that there are enough recent breakthroughs in quantum computing that I should take post-quantum cryptography seriously. <a href="https://blog.google/innovation-and-ai/technology/safety-security/cryptography-migration-timeline/">Google</a> and <a href="https://blog.cloudflare.com/post-quantum-roadmap/">Cloudflare</a> both set a target of 2029 for having their systems secure against quantum computers. Similarly, the <a href="https://www.ncsc.gov.uk/guidance/pqc-migration-timelines">UK government</a> is targeting 2035.</p>
<p>The issue is that cryptography is built upon math problems that are difficult to solve. Quantum computers make solving some of these problems such as integer factorization and discrete logs easier. If someone has a quantum computer that can sufficiently solve those two problems, then they can likely decrypt many ciphertexts that were produced using <a href="https://en.wikipedia.org/wiki/Public-key_cryptography">asymmetric cryptography</a> techniques (think public/private key-pairs). Wikipedia has a great article discussing <a href="https://en.wikipedia.org/wiki/Post-quantum_cryptography">post-quantum cryptography</a> if you want to read more.</p>
<p>Given all that, if the cost isn&rsquo;t too high then it&rsquo;s not a bad idea to look at our current systems and see what we can make quantum-resistant today. Otherwise, we risk being vulnerable to <a href="https://en.wikipedia.org/wiki/Harvest_now,_decrypt_later">harvest now, decrypt later</a> attacks. This is where an adversary stores encrypted packets until they have a computer powerful enough to break the encryption. You might wonder if encrypted packets from 5 years ago matter. Though if you&rsquo;re like me, chances are you had to transmit personally identifiable information over the internet for jobs, housing, etc. In the US, it is <a href="https://www.ssa.gov/faqs/en/questions/KA-02220.html">really difficult</a> to change your social security number.</p>
<p>We&rsquo;ll explore three different protocols I rely on and how we can make them post-quantum resistant.</p>
<h2 id="ssh">SSH</h2>
<p>OpenSSH implemented and made default post-quantum key agreement <a href="https://www.openssh.org/pq.html">back in April 2022</a>. At the time of writing, the <code>mlkem768x25519-sha256</code> scheme is used. That&rsquo;s a mouthful but it essentially describes what the scheme is:</p>
<ul>
<li><code>mlkem</code>: <a href="https://en.wikipedia.org/wiki/Lattice-based_cryptography">Module lattices</a> <a href="https://en.wikipedia.org/wiki/Key_encapsulation_mechanism">key encapsulation mechanism</a> (also known as key-exchange)</li>
<li><code>768</code>: Not sure what this means, sorry.</li>
<li><code>x25519</code>: <a href="https://en.wikipedia.org/wiki/Curve25519">Elliptic curve</a> used in the classical Diffie-Hellman key-exchange.</li>
<li><code>sha256</code>: The hash function used to <a href="https://datatracker.ietf.org/doc/draft-ietf-sshm-mlkem-hybrid-kex/">combine the keys</a> generated from ML-KEM and x25519 (see Section 2.4 of previous link).</li>
</ul>
<p>As you might notice, this is a hybrid quantum/classical algorithm. This is a hedge. If quantum computers arrive which break the classical algorithm, then we&rsquo;re safe. Similarly if we find out that the post-quantum algorithms are insecure, then we still have the well-developed classical ones. We can only hope that they&rsquo;re both not found to be insecure.</p>
<p>If you are using a client released after October 2025, then you&rsquo;ll receive a warning if you&rsquo;re connecting to a server that doesn&rsquo;t support post-quantum encryption.</p>
<pre tabindex="0"><code>** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to &#34;store now, decrypt later&#34; attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
</code></pre><h2 id="tls">TLS</h2>
<p><a href="https://en.wikipedia.org/wiki/Transport_Layer_Security">Transport Layer Security</a> is a cryptographic protocol that underlies HTTPS and many other applications. You&rsquo;re likely using it to connect to this website! Similar to OpenSSH, they introduced the hybrid scheme <code>X25519MLKEM768</code> for post-quantum security. The main difference that I can spot (as a non-cryptographer) is that this scheme does not hash the combined key. If you&rsquo;re interested, you can read more at this <a href="https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/">IETF draft</a>.</p>
<p>In terms of major browsers, Firefox has supported this since <a href="https://www.firefox.com/en-US/firefox/132.0/releasenotes/">October 2024</a> and Chrome since <a href="https://developer.chrome.com/release-notes/131">November 2024</a>. To use this scheme, however, both sides need to handle it. At the time of writing, <a href="https://radar.cloudflare.com/post-quantum">Cloudflare</a> estimates that 70.1% of Internet traffic is post-quantum encrypted. You can even use that link to check whether your own website supports post-quantum key exchange.</p>
<p>If you find that your website is not post-quantum secure, then check out the <a href="https://configurator.tlsref.org/">TLSRef TLS Configurator</a>. This provides the configuration options needed to support modern cryptographic protocols for a variety of popular web servers.</p>
<h2 id="wireguard">WireGuard</h2>
<p>WireGuard is a popular VPN technology that is known for being efficient and simple. By default, it is <a href="https://www.wireguard.com/known-limitations/">not post-quantum secure</a>. However, we can use the <code>PresharedKey</code> field to mix a symmetric key whose underlying mathematical problems are not as impacted as asymmetric cryptography.</p>
<p>The simplest solution here then is to run</p>
<pre tabindex="0"><code>wg genpsk
</code></pre><p>and then <em>securely</em> add the key into the <code>PresharedKey</code> field.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Interface]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Address</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">...</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">...</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">PrivateKey</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">...</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Peer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">PublicKey</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">...</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">...</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">PresharedKey</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">...</span>
</span></span></code></pre></div><p>Both sides of the connection need to use the same <code>PresharedKey</code> in order for the connection to work.</p>
<p>I&rsquo;ll reiterate that we need to figure out a way to transfer the key <em>securely</em> for this to work. WireGuard doesn&rsquo;t have a built-in way to share these keys. If you want to go with this simpler static preshared key route, then I suggest that you use a post-quantum secure channel (like a modern SSH setup) to distribute the keys.</p>
<p>The downside of setting the preshared key on both sides once and then moving on is that the tunnel is then not post-quantum forward secret. This means that if an adversary with access to a sufficiently powerful quantum computer additionally gets access to the preshared key, they can then decrypt all future ciphertexts.</p>
<p>If we want to protect against this, then we&rsquo;ll need to run a post-quantum key exchange protocol on top of WireGuard. At the time of writing, <a href="https://github.com/rosenpass/rosenpass">Rosenpass</a> seems to be the simplest way to set this up. They automatically update the preshared key securely within WireGuard every two minutes.</p>
<p>While that is one solution, the space here seems fragmented. Another approach <a href="https://ieeexplore.ieee.org/abstract/document/9519445">updates the protocol itself</a> and many VPN providers <a href="https://prod-assets-cms.mtech.xvservice.net/files/xv/Post-Quantum-WireGuard_-A-Practical-Implementation-Guide.pdf">handle it their own way as well</a>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Even though we&rsquo;re not at <a href="https://en.wikipedia.org/wiki/Harvest_now,_decrypt_later">Y2Q or Q-day</a>, we can still take steps to make sure that we&rsquo;re transmitting information over the Internet in a quantum-resistant way. In the meantime, I&rsquo;ll dream about the day when I can run a quantum computer in my pocket.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Synchronizing my Static Website with Object Storage</title>
  <link>https://brandonrozek.com/blog/synchronizing-static-website-object-storage/</link>
  <pubDate>Sat, 13 Jun 2026 17:51:08 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/synchronizing-static-website-object-storage/</guid>
  <description><![CDATA[<p>I recently updated all my <a href="/blog/implementing-cdn-geodns/">geo-distributed</a> web servers to run on Fedora CoreOS (<a href="/blog/fedora-coreos-first-impressions/">yes, I still love it</a>). This gave me an opportunity to revisit how I handle synchronization. Before, I used <a href="https://syncthing.net/">Syncthing</a> which while awesome is a pain to configure. I don&rsquo;t update my website or certs too frequently so having an always online setup seemed overkill.</p>
<p>So this time I went with an object storage setup.</p>
<p><img src="/files/images/blog/website-object-store.svg" alt=""></p>
<p>I created a bucket (e.g <code>my-website</code>) and within it I have the following directories</p>]]></description>
  <content:encoded><![CDATA[<p>I recently updated all my <a href="/blog/implementing-cdn-geodns/">geo-distributed</a> web servers to run on Fedora CoreOS (<a href="/blog/fedora-coreos-first-impressions/">yes, I still love it</a>). This gave me an opportunity to revisit how I handle synchronization. Before, I used <a href="https://syncthing.net/">Syncthing</a> which while awesome is a pain to configure. I don&rsquo;t update my website or certs too frequently so having an always online setup seemed overkill.</p>
<p>So this time I went with an object storage setup.</p>
<p><img src="/files/images/blog/website-object-store.svg" alt=""></p>
<p>I created a bucket (e.g <code>my-website</code>) and within it I have the following directories</p>
<pre tabindex="0"><code>my-website
├── etc
    └── letsencrypt
        └── live
            └── example.com
                ├── cert.pem
                ├── chain.pem
                ├── fullchain.pem
                └── privkey.pem
└── var
    └── www
        ├── website1
        ├── website2
        └── websiten
</code></pre><p>The core idea is that the webservers will read from this object store to stay up to date with my SSL certificates and my static website files. The rest of the post will go over how I 1) modified my deployment pipeline to push to the object store, 2) push the SSL certificates which are renewed, and 3) pull both the SSL certificates and the website files.</p>
<h2 id="deploying-my-website-files">Deploying my website files</h2>
<p>Currently, I use <a href="https://brandonrozek.com/blog/deploying-hugo-website-through-gh-actions/">Github Actions</a> (labeled as <code>CI/CD</code> in the diagram) to build and deploy my website. Beforehand, I had to create a special SSH key-pair and lock it down in case it leaked. For this new setup, we can instead use application keys to authenticate with our object store.</p>
<p>To lower the threat surface further, we can limit the buckets the application key has access to, whether it has read/write permissions, and what file prefixes the application can access.</p>
<p>For my Github action, I created an application key which has read/write permissions to the prefix <code>var/www/website</code>. We need both permissions if we want to delete files that don&rsquo;t exist in the build anymore. Here&rsquo;s the script that I use within the GitHub action to synchronize with the object store after I built the website.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>set -e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Environmental variables we need to set within the runner</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ENDPOINT_URL:?AWS_ENDPOINT_URL is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>S3_BUCKET:?S3_BUCKET is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>S3_PATH:?S3_PATH is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Safety check so we don&#39;t wipe our website!</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> ! -d <span style="color:#e6db74">&#34;public&#34;</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> <span style="color:#f92672">[</span> -z <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>ls -A public<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;public/ is empty or missing&#34;</span>
</span></span><span style="display:flex;"><span>  exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>aws s3 sync public/ <span style="color:#e6db74">&#34;s3://</span><span style="color:#e6db74">${</span>S3_BUCKET<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --delete <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --exclude <span style="color:#e6db74">&#34;*.bak&#34;</span>
</span></span></code></pre></div><p>Summarizing how the script works:</p>
<ul>
<li>The environmental variables at the beginning are used to authenticate with the object store.</li>
<li>We sanity check that the <code>public</code> folder exists after running <code>hugo</code> and that it&rsquo;s non-empty so that we don&rsquo;t accidentally wipe the object store&rsquo;s files.</li>
<li>We use the <code>aws</code> command to perform the sync. Note that this command is <a href="https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md">baked into</a> the default Ubuntu image used by GitHub&rsquo;s runners, so no installation step is required.</li>
</ul>
<h2 id="pushing-ssl-certificates">Pushing SSL Certificates</h2>
<p>I use <a href="https://certbot.eff.org/">Certbot</a> to request SSL certificates from Let&rsquo;s Encrypt. After a renewal certificate is issued, the client will run any scripts located within <code>/etc/letsencrypt/renewal-hooks/deploy</code> (<a href="https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates">documentation</a>). In that case, we want to add a <code>push-certs-to-object-store.sh</code> file which does just that.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>set -u
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>S3_BUCKET<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;INSERT_BUCKET_NAME&#34;</span>
</span></span><span style="display:flex;"><span>ENDPOINT<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;INSERT_ENDPOINT_URL&#34;</span>
</span></span><span style="display:flex;"><span>AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;INSERT_KEY_ID_HERE&#34;</span>
</span></span><span style="display:flex;"><span>AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;INSERT_SECRET_KEY_HERE&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>podman run --rm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -e AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -e AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -v <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>RENEWED_LINEAGE<span style="color:#e6db74">}</span><span style="color:#e6db74">:</span><span style="color:#e6db74">${</span>RENEWED_LINEAGE<span style="color:#e6db74">}</span><span style="color:#e6db74">:ro,z&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -v <span style="color:#e6db74">&#34;/etc/letsencrypt/archive:/etc/letsencrypt/archive:ro,z&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  docker.io/amazon/aws-cli s3 cp <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>RENEWED_LINEAGE<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  <span style="color:#e6db74">&#34;s3://</span><span style="color:#e6db74">${</span>S3_BUCKET<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>RENEWED_LINEAGE#/<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --recursive <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --endpoint-url <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>ENDPOINT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> $? -ne <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># Insert failure notification technique here</span>
</span></span><span style="display:flex;"><span>  exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Make sure that this script is executable after saving it. This script is different from the last in that it uses <code>podman</code> to run <code>aws-cli</code> as opposed to executing it directly. This is because I&rsquo;m using Fedora CoreOS an immutable distribution which <a href="https://docs.fedoraproject.org/en-US/fedora-coreos/faq/#_how_do_i_run_custom_applications_on_fedora_coreos">highly discourages overlaying packages</a>.</p>
<p>Going over the script:</p>
<ul>
<li><code>${RENEWED_LINEAGE}</code> is the folder path which contains the renewed certs (e.g <code>/etc/letsencrypt/live/example.com</code>)</li>
<li>We need to mount the <code>${RENEWED_LINEAGE}</code> path as well as <code>/etc/letsencrypt/archive</code> since the live folder only contains symbolic links to the files which are actually stored in the archive.</li>
<li>Since we&rsquo;re not modifying these files, we can treat them as read-only (the <code>ro</code> flag). Since I have SELinux enabled I threw in the <code>z</code> flag so that Podman can automatically handle the contexts for me.</li>
<li>We&rsquo;re using <code>cp</code> instead of <code>sync</code> since the renewal procedure will overwrite all the existing files with new ones. Meaning that we don&rsquo;t need read permissions for this application key to update the certificates.</li>
<li>Personally for alerting, I send a curl request to a webhook on failure.</li>
</ul>
<h2 id="pulling-the-files">Pulling the files</h2>
<p>We can create an application key with read-only permissions in order to pull the SSL certificates and the website files.</p>
<h3 id="website-files">Website Files</h3>
<p>Here&rsquo;s the script that I use to synchronize the local copy with that of the object store:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>set -e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ENDPOINT_URL:?AWS_ENDPOINT_URL is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>S3_BUCKET:?S3_BUCKET is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sync_site<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>    local path<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    mkdir -p <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    podman run --rm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      -e AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      -e AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      -v <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">:/</span><span style="color:#e6db74">${</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">:z&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      docker.io/amazon/aws-cli s3 sync <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      <span style="color:#e6db74">&#34;s3://</span><span style="color:#e6db74">${</span>S3_BUCKET<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      --delete <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>      --endpoint-url <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ENDPOINT_URL<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sync_site <span style="color:#e6db74">&#34;var/www/website1&#34;</span>
</span></span><span style="display:flex;"><span>sync_site <span style="color:#e6db74">&#34;var/www/website2&#34;</span>
</span></span><span style="display:flex;"><span>sync_site <span style="color:#e6db74">&#34;var/www/websiten&#34;</span>
</span></span></code></pre></div><p>I have a corresponding systemd unit file and timer which runs every 15 minutes to check for changes.</p>
<h3 id="ssl-certificates">SSL Certificates</h3>
<p>For my certificates, I only check for new ones daily. The script is very similar&hellip;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>set -e
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ENDPOINT_URL:?AWS_ENDPOINT_URL is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>S3_BUCKET:?S3_BUCKET is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>: <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>S3_PATH:?S3_PATH is not set<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>mkdir -p -m <span style="color:#ae81ff">700</span> <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>podman run --rm <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -e AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ACCESS_KEY_ID<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -e AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_SECRET_ACCESS_KEY<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -v <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span>:<span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span>:z <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  docker.io/amazon/aws-cli s3 cp <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  <span style="color:#e6db74">&#34;s3://</span><span style="color:#e6db74">${</span>S3_BUCKET<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  <span style="color:#e6db74">&#34;/</span><span style="color:#e6db74">${</span>S3_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --recursive <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --endpoint-url <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>AWS_ENDPOINT_URL<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>That&rsquo;s at least how I have it set up at the time of writing. I&rsquo;ve only been running this setup for two days, so we&rsquo;ll see how I feel ultimately. Right now, I&rsquo;m happy that it simplifies my Ansible setup for these servers. Before this, I had to manually setup Syncthing using their webui.</p>
<p>This updated method allows me to copy a few scripts and systemd unit files over and call it a day. I&rsquo;m also happy that I don&rsquo;t have to deal with creating a special <code>build</code> user and making sure that&rsquo;s locked down. Now we&rsquo;ll see if I can be patient for 15 minutes to see my website changes ;D</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Postroll: Pac-Man, but you&#39;re the ghost</title>
  <link>https://garrit.xyz/posts/2026-06-13-pac-man-but-you-re-the-ghost</link>
  <pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate>
  <author>Garrit Franke</author>
  <guid>https://garrit.xyz/posts/2026-06-13-pac-man-but-you-re-the-ghost</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>This was a fun game, try it out!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>This was a fun game, try it out!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Brocards for vulnerability triage</title>
  <link>https://blog.yossarian.net/2026/04/11/Brocards-for-vulnerability-triage</link>
  <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
  <author>William Woodruff</author>
  <guid>https://blog.yossarian.net/2026/04/11/Brocards-for-vulnerability-triage</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Even though brocard is a weird name for them, I enjoyed reading the principles that William uses to triage vulnerabilities.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Even though brocard is a weird name for them, I enjoyed reading the principles that William uses to triage vulnerabilities.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: SSH certificates: the better SSH experience</title>
  <link>https://jpmens.net/2026/04/03/ssh-certificates-the-better-ssh-experience/</link>
  <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
  <author>Jan-Piet Mens</author>
  <guid>https://jpmens.net/2026/04/03/ssh-certificates-the-better-ssh-experience/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Using a certificate authority to sign a user&rsquo;s public SSH key instead of storing them on the machine is pretty neat. I&rsquo;ve worked on systems before where we had short-lived SSH keys and I imagine that the setup had to be similar to what&rsquo;s presented here. If I scaled up and down infrastructure in my homelab more, then I can totally see myself implementing this.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Using a certificate authority to sign a user&rsquo;s public SSH key instead of storing them on the machine is pretty neat. I&rsquo;ve worked on systems before where we had short-lived SSH keys and I imagine that the setup had to be similar to what&rsquo;s presented here. If I scaled up and down infrastructure in my homelab more, then I can totally see myself implementing this.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: How to check out selected directories from Git repository</title>
  <link>https://sleeplessbeastie.eu/2026/05/28/how-to-check-out-selected-directories-from-git-repository/</link>
  <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
  <author>Milosz Galazka</author>
  <guid>https://sleeplessbeastie.eu/2026/05/28/how-to-check-out-selected-directories-from-git-repository/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Woah! I didn&rsquo;t know that you can do sparse checkouts with git! That was one of my favorite features from <a href="https://svnbook.red-bean.com/en/1.7/svn.advanced.sparsedirs.html">subversion</a>. This makes working with large mono-repos more bearable.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Woah! I didn&rsquo;t know that you can do sparse checkouts with git! That was one of my favorite features from <a href="https://svnbook.red-bean.com/en/1.7/svn.advanced.sparsedirs.html">subversion</a>. This makes working with large mono-repos more bearable.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Taxing Small Cars to Improve MPG</title>
  <link>https://www.jefftk.com/p/taxing-small-cars-to-improve-mpg</link>
  <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
  <author>Jeff Kaufman</author>
  <guid>https://www.jefftk.com/p/taxing-small-cars-to-improve-mpg</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I also own a Honda Fit and am sad that they discontinued them in the US. Not only are smaller vehicles more fuel efficient, they&rsquo;re also easier to parallel park!</p>
<p>I&rsquo;m not sure when the government set these CAFE MPG targets, but they seem quite aspirational! Even though I would love a small car that drives 70 MPG, it&rsquo;s rough that these regulations are incentivizing companies to focus on larger vehicles.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I also own a Honda Fit and am sad that they discontinued them in the US. Not only are smaller vehicles more fuel efficient, they&rsquo;re also easier to parallel park!</p>
<p>I&rsquo;m not sure when the government set these CAFE MPG targets, but they seem quite aspirational! Even though I would love a small car that drives 70 MPG, it&rsquo;s rough that these regulations are incentivizing companies to focus on larger vehicles.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: The Archivist In Me Turned This Blog Into a Book</title>
  <link>https://brainbaking.com/post/2026/06/the-archivist-in-me-turned-this-blog-into-a-book/</link>
  <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
  <author>Wouter Groeneveld</author>
  <guid>https://brainbaking.com/post/2026/06/the-archivist-in-me-turned-this-blog-into-a-book/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>What a crazy and fun project! I can imagine archiving a blog as a journal/periodical where you have volume numbers. For example, &ldquo;Brandon Rozek&rsquo;s Ramblings Volume 1&rdquo;.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>What a crazy and fun project! I can imagine archiving a blog as a journal/periodical where you have volume numbers. For example, &ldquo;Brandon Rozek&rsquo;s Ramblings Volume 1&rdquo;.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Can you tell the difference? A quick look into discrimination testing.</title>
  <link>https://brandonrozek.com/blog/discrimination-testing/</link>
  <pubDate>Sun, 24 May 2026 15:32:35 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/discrimination-testing/</guid>
  <description><![CDATA[<p>A few month&rsquo;s ago, Brad Reese, the grandson of the founder of the Reese&rsquo;s company, called the company out for <a href="https://www.linkedin.com/posts/bradreesecom_reeses-brandstewardship-corporateaccountability-activity-7428545969430016001-zOL7/">swapping out the chocolate and peanut butter in some of their products</a>. The newer ingredients &ldquo;chocolate candy&rdquo; and &ldquo;peanut butter creme&rdquo; are only imitations.</p>
<blockquote>
<p>But today, REESE&rsquo;S  identity is being rewritten, not by storytellers, but by formulation  decisions that replace Milk Chocolate with compound coatings and Peanut  Butter with peanut‑butter‑style crèmes across multiple REESE’S products.</p>]]></description>
  <content:encoded><![CDATA[<p>A few month&rsquo;s ago, Brad Reese, the grandson of the founder of the Reese&rsquo;s company, called the company out for <a href="https://www.linkedin.com/posts/bradreesecom_reeses-brandstewardship-corporateaccountability-activity-7428545969430016001-zOL7/">swapping out the chocolate and peanut butter in some of their products</a>. The newer ingredients &ldquo;chocolate candy&rdquo; and &ldquo;peanut butter creme&rdquo; are only imitations.</p>
<blockquote>
<p>But today, REESE&rsquo;S  identity is being rewritten, not by storytellers, but by formulation  decisions that replace Milk Chocolate with compound coatings and Peanut  Butter with peanut‑butter‑style crèmes across multiple REESE’S products.</p>
<p>- Brad Reese</p></blockquote>
<p>Jonathan Deutsch over at the Conversation wrote that product reformulations in foods are common and most of the time <a href="https://theconversation.com/controversy-over-reeses-ingredients-reveals-standard-food-industry-practices-most-consumers-never-notice-276808">we don&rsquo;t even notice</a>. This along with shrinking the size of the product are the two main approaches companies use to reduce costs.</p>
<h2 id="discrimination-testing-for-food">Discrimination Testing for Food</h2>
<p>So how can companies be confident that few will notice? That&rsquo;s where discrimination testing comes in. From my research and what Jonathan shared, the most common type of discrimination test used in the food science industry is the <a href="https://www.sensorysociety.org/knowledge/sspwiki/Pages/Triangle%20Test.aspx">triangle test</a>.</p>
<p>In this test, the participant is given three products (A, B, C) and are told that only one of them is different. If the participant is able to correctly identify which one, then there&rsquo;s evidence that the average person can do the same. The probability that a random guess is correct is 1/3. Therefore, it&rsquo;s not sufficient to ask a single person and instead we need many participants to obtain statistical significance.</p>
<h2 id="discrimination-testing-for-audio">Discrimination Testing for Audio</h2>
<p>A few weeks later, I read Andreas&rsquo; post on <a href="https://82mhz.net/posts/2026/03/can-i-hear-a-difference-between-mp3s-and-uncompressed-audio/">telling the difference between compressed and uncompressed audio</a>. This made me curious on what discrimination tests are commonly used in the audio setting. However, this was difficult for me to search for.</p>
<p>In the food setting, I&rsquo;ve found multiple presentations and publications by the <a href="https://www.sensorysociety.org/Pages/default.aspx">Society of Sensory Professionals</a> and the <a href="https://www.ifpress.com/">Institute for Perception</a>. I wasn&rsquo;t able to easily find any groups focused on the audio setting. As our last resort, we&rsquo;ll trust Wikipedia and say that <a href="https://en.wikipedia.org/wiki/ABX_test">ABX Testing</a> is commonly used.</p>
<p>In ABX testing, the participant is told which sample is A and which sample is B. The task is then to guess whether X = A or X = B. What&rsquo;s important here is that the participant knows that A is not equal to B. Hence, the participant is provided with more information than in the triangle test setting.</p>
<p>For our audio example, we can say that A is the uncompressed audio, B is the compressed audio, and the task is to guess which X is. The probability of a random guess being correct in this test is 1/2 which is much higher than the triangle test.</p>
<p>Honestly, I can&rsquo;t figure out why this test is more common than the others. If you&rsquo;re a perception researcher and have some insight, please get in touch.</p>
<p>One thing to note about discrimination testing is that it only tells us if there is a perceptible difference. When comparing compression algorithms, we often want feedback on the perceived quality differences. I didn&rsquo;t look into this area much, but if you&rsquo;re interested then the <a href="https://en.wikipedia.org/wiki/MUSHRA">MUSHRA</a> method seems to be a good starting point.</p>
<h2 id="the-tip-of-the-iceburg">The Tip of the Iceburg</h2>
<p>As you might have noticed by now, I&rsquo;m not a perception researcher. There are a lot of discrimination tests out there and many folks are working on new ideas in this space. To close out this quick introduction,  I&rsquo;ll share two other approaches that I came across when writing this post.</p>
<p>First, there&rsquo;s the <a href="https://www.sensorysociety.org/knowledge/sspwiki/Pages/Two-out-of-five%20Test.aspx">two out of five test</a>. It&rsquo;s similar to the triangle test where the participant is given no labels but this time they&rsquo;re asked to identify the two that are unlike the other three. The probability of randomly guessing correctly in this test is 1/10 which is by far the lowest out of the approaches I shared in this post. The downside to this test is that the participant has to sample five items which might cause sensory fatigue and it&rsquo;s easy to give <a href="https://onlinelibrary.wiley.com/doi/10.1111/joss.12044">incorrect instructions</a>.</p>
<p><a href="https://www.ifpress.com/tr-15-1">Tetrad testing</a> is when the participant is given 4 samples and are asked to pair up the equivalent ones. The probability of guessing correctly is equivalent to that of the triangle test, but Ennis and Jesionka show that in some cases the Tetrad test <a href="https://doi.org/10.1111/j.1745-459X.2011.00353.x">requires one third the number of participants as that required by the triangle test</a>. The Institute for Perception released a techincal report arguing for the switch to Tetrad testing in order to <a href="https://www.ifpress.com/tr-15-1">reduce costs</a>. There&rsquo;s even a fun presentation publicly available by Hannah Lemar showing the suitability of the Tetrad test in the <a href="https://www.asbcnet.org/events/archives/2016/proceedings/Documents/8_Lemar.pdf">brewing industry</a>.</p>
<p>What do you think, is it important to perceive the original, or does an imitation work for you? For me, I don&rsquo;t mind listening to compressed audio at all. I like to think, however, that I&rsquo;m aware of what I&rsquo;m eating.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Praise the Smart Button</title>
  <link>https://brandonrozek.com/blog/praise-smart-button/</link>
  <pubDate>Fri, 22 May 2026 20:07:42 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/praise-smart-button/</guid>
  <description><![CDATA[<img alt="Lamp which has a giraffe as it's base" height="500px" src="/files/images/blog/202605222012.jpg" />
<br/>

<p>Meet Giraffe. Equipped with an <a href="https://www.zigbee2mqtt.io/devices/AE_270_T.html">Innr AE 270 T</a> smart bulb, it allows me to turn the light on and off from our phones using <a href="https://www.home-assistant.io/">Home Assistant</a>. The main issue? I need my phone to turn it on and off.</p>
<p>Now don&rsquo;t get me wrong, I can still walk up to the lamp and flip the switch. But, then I need to walk back up to it in order to turn it back on. Turns out, the smart bulb needs some power to receive control messages via the network.</p>]]></description>
  <content:encoded><![CDATA[
<img alt="Lamp which has a giraffe as it's base" height="500px" src="/files/images/blog/202605222012.jpg" />
<br/>

<p>Meet Giraffe. Equipped with an <a href="https://www.zigbee2mqtt.io/devices/AE_270_T.html">Innr AE 270 T</a> smart bulb, it allows me to turn the light on and off from our phones using <a href="https://www.home-assistant.io/">Home Assistant</a>. The main issue? I need my phone to turn it on and off.</p>
<p>Now don&rsquo;t get me wrong, I can still walk up to the lamp and flip the switch. But, then I need to walk back up to it in order to turn it back on. Turns out, the smart bulb needs some power to receive control messages via the network.</p>
<p>If that wasn&rsquo;t annoying enough, flipping the switch off would mess with my cool automations. At sunrise, it&rsquo;s supposed to turn on to help wake me up and then automatically turn itself off after noon.</p>
<p>So I lived with that reality for a few years. Whenever I want to turn on or off the lamp, I would pull out my phone. Forgot to turn off the lights when I left the house? No worries, I can still control it remotely.</p>
<p>But that all changed this Christmas when my wife gifted me the following&hellip;</p>

<img alt="Lamp which has a giraffe as it's base" height="500px" src="/files/images/blog/202605222013.jpg" />
<br/>

<p>That&rsquo;s right. It&rsquo;s a <a href="https://www.zigbee2mqtt.io/devices/3RSB22BZ.html">Third Reality 3RSB22BZ</a> smart button. Setting this up was relatively straightforward. On Home Assistant, I clicked on &ldquo;Add Device&rdquo; and then specified that I wanted to add a Zigbee device. This requires a Zigbee hub, and for that I use the <a href="https://www.home-assistant.io/connect/zbt-2/">Home Assistant Connect ZBT-2</a>. After that, I went through a short pairing process and tada the device is added!</p>
<p>By itself, the button doesn&rsquo;t actually do anything. To change that, we need to set up automations. At the time of writing, Home Assistant specifies automations through triggers, conditions, and actions. I set my trigger to <code>remote_button_short_press</code>. I didn&rsquo;t add any extra conditions, and my action is to toggle my Giraffe lamp.</p>
<p>With that, I don&rsquo;t need to pull out my phone anymore to control the light!</p>
<hr>
<p>I&rsquo;ve been meaning to write more about my Home Assistant setup, but I&rsquo;m not sure what would be useful to share. For now I&rsquo;ll write what&rsquo;s on my mind, but feel free to get in touch if you want me to share more.</p>
<p>I started off my Home Assistant journey by purchasing smart bulbs and smart switches flashed with the <a href="https://tasmota.github.io/docs/">Tasmota firmware</a>. The devices specifically connected to my local WiFi network and sent messages to my MQTT server.</p>
<p>However, I wasn&rsquo;t the happiest with the quality of my smart bulbs. I came across itchaboyagin&rsquo;s post on the <a href="https://community.home-assistant.io/t/i-just-finished-testing-over-150-of-the-best-smart-lights-here-s-all-the-data/764760">Home Assistant forum</a> where they shared a <a href="https://optimizeyourbiology.com/smart-light-database/">database</a> of 120 different smart bulbs full of metrics. What I was looking for at the time escaped by head, but I believe I focused on bulbs with no flicker risk and great <a href="https://en.wikipedia.org/wiki/Color_rendering_index">color quality</a>.</p>
<p>That&rsquo;s when I came across the Innr bulb and noticed that it relied on Zigbee for connectivity.
This means that instead of connecting to my WiFI, it creates it&rsquo;s own mesh network and requires a hub to work. So&hellip; I needed to purchase an extra device. Luckily, many companies support the Zigbee standard, so I told myself that this would open up oppurtunities for future devices.</p>
<p>Overall, I&rsquo;ve been rocking this new setup for the last 6 months and I&rsquo;m happy.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Postroll: A Year Of Eggs</title>
  <link>https://fyr.io/post/a-year-of-eggs</link>
  <pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate>
  <author>Matt</author>
  <guid>https://fyr.io/post/a-year-of-eggs</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Chickens are funny</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Chickens are funny</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: On Take-Home Coding Assignments</title>
  <link>https://kittygiraudel.com/2026/05/08/on-take-home-coding-assignments/</link>
  <pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate>
  <author>Kitty Giraudel</author>
  <guid>https://kittygiraudel.com/2026/05/08/on-take-home-coding-assignments/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I recently went through several rounds of interviews and from my experience startups prefer take-home coding assignments while larger companies wanted me to give a talk.</p>
<p>Honestly, both those tasks require work. I get it though &ndash; it&rsquo;s difficult for companies to assess the competency of their applicants. Kitty provides other alternate assessments in this post that employers should consider using.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I recently went through several rounds of interviews and from my experience startups prefer take-home coding assignments while larger companies wanted me to give a talk.</p>
<p>Honestly, both those tasks require work. I get it though &ndash; it&rsquo;s difficult for companies to assess the competency of their applicants. Kitty provides other alternate assessments in this post that employers should consider using.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: The Problem of Pedagogy in Advanced Mathematics</title>
  <link>https://susam.net/advanced-mathematics-pedagogy.html</link>
  <pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate>
  <author>Susam Pal</author>
  <guid>https://susam.net/advanced-mathematics-pedagogy.html</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I resonate with Susam about how &ldquo;proofs&rdquo; written in many textbooks aren&rsquo;t complete proofs but more outlines. In addition to &ldquo;the proof being obvious&rdquo;, I would say that many of the details are left &ldquo;as an exercise to the reader&rdquo;. Sometimes I share <a href="/blog/implications-prenex-normal-form/">such</a> <a href="/blog/expectations-are-linear/">exercises</a> on this blog.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I resonate with Susam about how &ldquo;proofs&rdquo; written in many textbooks aren&rsquo;t complete proofs but more outlines. In addition to &ldquo;the proof being obvious&rdquo;, I would say that many of the details are left &ldquo;as an exercise to the reader&rdquo;. Sometimes I share <a href="/blog/implications-prenex-normal-form/">such</a> <a href="/blog/expectations-are-linear/">exercises</a> on this blog.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Contributor Poker and Zig&#39;s AI Ban</title>
  <link>https://kristoff.it/blog/contributor-poker-and-ai/</link>
  <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
  <author>Loris Cro</author>
  <guid>https://kristoff.it/blog/contributor-poker-and-ai/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>There seems to be a misunderstanding in some folks where they believe that the end product is all that matters and that code quality or the social components of open source are unimportant.</p>
<p>Loris shares how the Zig language views folks who submit PRs as potential future collaborators. They hope you&rsquo;ll come back and be part of the community! Vibe coding a feature completely doesn&rsquo;t help build the context required to become a maintainer.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>There seems to be a misunderstanding in some folks where they believe that the end product is all that matters and that code quality or the social components of open source are unimportant.</p>
<p>Loris shares how the Zig language views folks who submit PRs as potential future collaborators. They hope you&rsquo;ll come back and be part of the community! Vibe coding a feature completely doesn&rsquo;t help build the context required to become a maintainer.</p>
<p>They&rsquo;re not unique in that stance. Matplotlib has a policy <a href="https://matplotlib.org/devdocs/devel/contribute.html#generative-ai">against AI generated submissions</a> on good first issues exactly for this reason.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: The First of a Double Take</title>
  <link>https://ratfactor.com/cards/first-of-a-double</link>
  <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
  <author>Dave Gauer</author>
  <guid>https://ratfactor.com/cards/first-of-a-double</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I like the idea of preserving a first take instead of letting it flee from your short term memory. Dave&rsquo;s &ldquo;Keep Your Head&rdquo; piece makes me laugh ;D</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I like the idea of preserving a first take instead of letting it flee from your short term memory. Dave&rsquo;s &ldquo;Keep Your Head&rdquo; piece makes me laugh ;D</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Baked Salmon</title>
  <link>https://brandonrozek.com/menu/baked-salmon/</link>
  <pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/menu/baked-salmon/</guid>
  <description><![CDATA[<p>Prep Time: 5 minutes</p>
<p>Cook Time: 10 minutes</p>
<h1 id="ingridients">Ingridients</h1>
<ul>
<li>Salmon</li>
<li>1 tblspoon lemon</li>
<li>3 tblspoon soy sauce</li>
<li>some amount of honey</li>
<li>2 tablespoons of butter</li>
<li>Sushi Rice</li>
<li>Kewpie Mayo</li>
<li>Avocado</li>
<li>Nori (Optional)</li>
</ul>
<h1 id="recipe">Recipe</h1>
<ol>
<li>Set oven to 450 degrees</li>
<li>Put the rice with equal parts water in a rice cooker and get that started.</li>
<li>Grab a small bowl and make the sauce by mixing the soy sauce, lemon, butter, and honey. Make sure the butter is melted, one way is to use the microwave.</li>
<li>Pour the sauce mixture onto the salmon itself in a oven-safe dish.</li>
<li>When the oven is ready, pop it in there for 10 minutes. The salmon should be 145 degrees fahrenheit.</li>
<li>Once out, peel off the skin from the salmon.</li>
<li>Grab a large mixing bowl and put in the cooked rice and salmon.</li>
<li>Put some of the remaining sauce from the oven dish into the mixing bowl. How much is really a preference.</li>
<li>Add some amount of kewpie mayo.</li>
<li>Carefully mix. You don&rsquo;t want to mush up the salmon. It will naturally break itself in one direction.</li>
<li>Dice up and add avocados to the mixing bowl.</li>
</ol>
<p>Serve in a bowl (with nori) and enjoy!</p>]]></description>
  <content:encoded><![CDATA[<p>Prep Time: 5 minutes</p>
<p>Cook Time: 10 minutes</p>
<h1 id="ingridients">Ingridients</h1>
<ul>
<li>Salmon</li>
<li>1 tblspoon lemon</li>
<li>3 tblspoon soy sauce</li>
<li>some amount of honey</li>
<li>2 tablespoons of butter</li>
<li>Sushi Rice</li>
<li>Kewpie Mayo</li>
<li>Avocado</li>
<li>Nori (Optional)</li>
</ul>
<h1 id="recipe">Recipe</h1>
<ol>
<li>Set oven to 450 degrees</li>
<li>Put the rice with equal parts water in a rice cooker and get that started.</li>
<li>Grab a small bowl and make the sauce by mixing the soy sauce, lemon, butter, and honey. Make sure the butter is melted, one way is to use the microwave.</li>
<li>Pour the sauce mixture onto the salmon itself in a oven-safe dish.</li>
<li>When the oven is ready, pop it in there for 10 minutes. The salmon should be 145 degrees fahrenheit.</li>
<li>Once out, peel off the skin from the salmon.</li>
<li>Grab a large mixing bowl and put in the cooked rice and salmon.</li>
<li>Put some of the remaining sauce from the oven dish into the mixing bowl. How much is really a preference.</li>
<li>Add some amount of kewpie mayo.</li>
<li>Carefully mix. You don&rsquo;t want to mush up the salmon. It will naturally break itself in one direction.</li>
<li>Dice up and add avocados to the mixing bowl.</li>
</ol>
<p>Serve in a bowl (with nori) and enjoy!</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Postroll: Making wooden skies</title>
  <link>https://erikjohannes.no/posts/20260430-making-wooden-skis/index.html</link>
  <pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate>
  <author>Erik Johannes Husom</author>
  <guid>https://erikjohannes.no/posts/20260430-making-wooden-skis/index.html</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I love the pictures of the skiis at different stages!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I love the pictures of the skiis at different stages!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Paella</title>
  <link>https://brandonrozek.com/menu/paella/</link>
  <pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/menu/paella/</guid>
  <description><![CDATA[<p>Inspired from this <a href="https://www.simplyrecipes.com/recipes/pressure_cooker_paella_with_chicken_and_sausage/">recipe</a>.</p>
<p>This dish is traditionally made with white rice and it
is tastier that way, but I&rsquo;ve been experimenting with eating
more brown rice recently.</p>
<h1 id="ingridients">Ingridients</h1>
<ul>
<li>1.5 cups of brown/white rice</li>
<li>2.5 cups of chicken broth if brown rice used otherwise 1.5 cups</li>
<li>1 bag of frozen peas</li>
<li>2 shallots</li>
<li>Red bell pepper</li>
<li>Chorizo</li>
<li>Half tablespoon tumeric</li>
<li>1 teaspoon smoked paprika</li>
<li>1 teaspoon salt</li>
</ul>
<h1 id="recipe">Recipe</h1>
<ol>
<li>Dice up the bell pepper and shallots, and throw onto a pan on the stove.</li>
<li>Take the chorizo and slice it up.</li>
<li>Saute pepper, shallots, and chorizo on the pan for 5 minutes.</li>
<li>Throw everything into the instant pot.</li>
<li>Set the pressure cook to high and 25 minutes</li>
<li>Let it naturally release for 10 minutes</li>
</ol>
<p>Enjoy!</p>]]></description>
  <content:encoded><![CDATA[<p>Inspired from this <a href="https://www.simplyrecipes.com/recipes/pressure_cooker_paella_with_chicken_and_sausage/">recipe</a>.</p>
<p>This dish is traditionally made with white rice and it
is tastier that way, but I&rsquo;ve been experimenting with eating
more brown rice recently.</p>
<h1 id="ingridients">Ingridients</h1>
<ul>
<li>1.5 cups of brown/white rice</li>
<li>2.5 cups of chicken broth if brown rice used otherwise 1.5 cups</li>
<li>1 bag of frozen peas</li>
<li>2 shallots</li>
<li>Red bell pepper</li>
<li>Chorizo</li>
<li>Half tablespoon tumeric</li>
<li>1 teaspoon smoked paprika</li>
<li>1 teaspoon salt</li>
</ul>
<h1 id="recipe">Recipe</h1>
<ol>
<li>Dice up the bell pepper and shallots, and throw onto a pan on the stove.</li>
<li>Take the chorizo and slice it up.</li>
<li>Saute pepper, shallots, and chorizo on the pan for 5 minutes.</li>
<li>Throw everything into the instant pot.</li>
<li>Set the pressure cook to high and 25 minutes</li>
<li>Let it naturally release for 10 minutes</li>
</ol>
<p>Enjoy!</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Postroll: Who Is That Knocking At My (SSH) Door?</title>
  <link>https://sheep.horse/2026/4/who_is_that_knocking_at_my_%28ssh%29_door.html</link>
  <pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate>
  <author>Andrew Stephens</author>
  <guid>https://sheep.horse/2026/4/who_is_that_knocking_at_my_%28ssh%29_door.html</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I try not to think about how my machines are being constantly attacked by bots. I think having <code>sheep</code> as a username is cool, but maybe I shouldn&rsquo;t do that ;)</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I try not to think about how my machines are being constantly attacked by bots. I think having <code>sheep</code> as a username is cool, but maybe I shouldn&rsquo;t do that ;)</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Expectations are Linear</title>
  <link>https://brandonrozek.com/blog/expectations-are-linear/</link>
  <pubDate>Sun, 26 Apr 2026 09:03:28 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/expectations-are-linear/</guid>
  <description><![CDATA[<blockquote>
<p>As an example, he asked me, in more words, what the expected rank when flipping over the top card of a deck of cards was (A=1, J=11, Q=12,  K=13). This is easy to compute directly as 7. Then he asked me the expectation of the <em>sum of the top two cards</em>.</p>
<p>- <a href="https://buttondown.com/jaffray/archive/expectation-and-copysets/">From &ldquo;Expectation and Copysets&rdquo; by Justin Jaffray</a></p></blockquote>
<p>What does your intuition say the answer is? Justin continues by stating that computing this expectation is as easy as summing their individual expectations.
$$
E[X + Y] = E[X] + E[Y]
$$
In other words, <strong>expectations are linear</strong>. I recommend reading his entire blog post. It&rsquo;s great and also talks about how this property is used in databases today. After a high-level explanation, he says:</p>]]></description>
  <content:encoded><![CDATA[<blockquote>
<p>As an example, he asked me, in more words, what the expected rank when flipping over the top card of a deck of cards was (A=1, J=11, Q=12,  K=13). This is easy to compute directly as 7. Then he asked me the expectation of the <em>sum of the top two cards</em>.</p>
<p>- <a href="https://buttondown.com/jaffray/archive/expectation-and-copysets/">From &ldquo;Expectation and Copysets&rdquo; by Justin Jaffray</a></p></blockquote>
<p>What does your intuition say the answer is? Justin continues by stating that computing this expectation is as easy as summing their individual expectations.
$$
E[X + Y] = E[X] + E[Y]
$$
In other words, <strong>expectations are linear</strong>. I recommend reading his entire blog post. It&rsquo;s great and also talks about how this property is used in databases today. After a high-level explanation, he says:</p>
<blockquote>
<p>The fact that expectation is linear is easy to show if you just look at the definition, which we will not do here, but I trust you are capable of if you are interested and have not already seen it.</p></blockquote>
<p>In this episode of <em>Exercise for the Reader</em> (<a href="/blog/implications-prenex-normal-form/">last episode</a>), we&rsquo;ll look at the definition and show why this property holds. This is true regardless of the underlying probability distribution and whether or not we&rsquo;re sampling with replacement.</p>
<p>As Justin stated, let&rsquo;s start with the definition of expectation and then split the sum:
$$
\begin{align*}
E[X + Y] &amp;= \sum_{x \in X} \sum_{y \in Y} (x + y) \cdot P(X = x, Y=y) \\
&amp;= (\sum_{x \in X} \sum_{y \in Y} x \cdot P(X = x, Y = y)) + (\sum_{x \in X} \sum_{y \in Y} y \cdot P(X = x, Y = y))
\end{align*}
$$
Notice that the left-hand-side of the multiplication does not depend on both variables anymore. Also it doesn&rsquo;t matter whether we do the summation over $X$ first or $Y$. Therefore, we can bring that variable out of the inner sum and simplify this to:
$$
E[X + Y] = (\sum_{x \in X} x \sum_{y \in Y} P(X = x, Y = y)) + (\sum_{y \in Y} y \sum_{x \in X} P(X = x, Y = y))
$$
We can then perform <em>marginalization</em> to substitute $\sum_{y \in Y} P(X = x, Y = y)$ with $P(X = x)$ and do the same for the right hand side of the sum.
$$
\begin{align*}
E[X + Y] &amp;= (\sum_{x \in X}xP(X = x)) + (\sum_{y \in y}yP(Y=y))) \\
&amp;= E[x] + E[Y]
\end{align*}
$$</p>
<hr>
<p>Why can we marginalize? For me to show why, we need to peel back the curtain on the notation.</p>
<p>The set $\Omega$ contains the outcomes of all the events that we&rsquo;re concerned about. So, if we are considering events $X$ and $Y$ with outcomes $x_i$ and $y_i$, respectively. Then, our event space $\Omega$ is equal to $\{ (x_i, y_i) \mid x_i \in X, y_i \in Y\}$.</p>
<p>Therefore when we say $X = x$, what we really mean is the set of outcomes where that is true. In mathematical terms, $\{\omega \in \Omega \mid X(\omega) = x\}$.</p>
<p>Now, let&rsquo;s show why $\sum_{y \in Y} P(X = x, Y = y) = P(X = x)$.
$$
\sum_{y \in Y}P(X = x, Y = y) = \sum_{y \in Y} P(\{\omega \in \Omega \mid X(\omega) = x\} \cap \{\omega \in \Omega \mid Y(\omega) = y\})
$$
One of the three Kolmogorov axioms of probability is <strong>countable additivity</strong>. This is defined as:
$$
\sum_{x \in A}P(X = x) = P(\bigcup_{x \in A}X =x )
$$
Substituting that in and simplifying, we get:
$$
\begin{align*}
\sum_{y \in Y}P(X = x, Y = y) &amp;= P(\bigcup_{y \in Y}(\{\omega \in \Omega \mid X(\omega) = x\} \cap \{\omega \in \Omega \mid Y(\omega) = y\})) \\
&amp;= P(\{\omega \in \Omega \mid X(\omega) = x\} \cap \bigcup_{y \in Y}\{\omega \in \Omega \mid Y(\omega) = y\})
\end{align*}
$$
Notice that the right hand side of the term is just $\Omega$. We can then simplify to,
$$
\begin{align*}
\sum_{y \in Y}P(X = x, Y = y) &amp;= P(\{\omega \in \Omega \mid X(\omega) = x\} \cap \Omega) \\
&amp;= P(\{\omega \in \Omega \mid X(\omega) = x\}) \\
&amp;= P(X = x)
\end{align*}
$$
Since countable additivity is an axiom, we&rsquo;ll stop our derivations there.  See you next time.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Postroll: Design as a Demonstrator</title>
  <link>https://smithery.com/2026/04/22/design-as-a-demonstrator/</link>
  <pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate>
  <author>John V Willshire</author>
  <guid>https://smithery.com/2026/04/22/design-as-a-demonstrator/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Great reminder to build what you want to see in the world. Hedgehogs are cool!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Great reminder to build what you want to see in the world. Hedgehogs are cool!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Weather Model based on ADS-B</title>
  <link>https://obrhubr.org/adsb-weather-model</link>
  <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
  <author>Niklas Oberhuber</author>
  <guid>https://obrhubr.org/adsb-weather-model</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Using ADS-B data from planes to capture wind speed data is really cool! I never thought about how we have thousands of little wind sensors flying around in the sky.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Using ADS-B data from planes to capture wind speed data is really cool! I never thought about how we have thousands of little wind sensors flying around in the sky.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Anatomy of Iran&#39;s Internet</title>
  <link>https://rb.ax/blog/anatomy-of-irans-internet/</link>
  <pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate>
  <author>Ryan Bagley</author>
  <guid>https://rb.ax/blog/anatomy-of-irans-internet/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Very informative piece on how Iran structures their internet infrastructure. It discusses the incentives developed over many years as well as some of the underlying assumptions that are being challenged.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Very informative piece on how Iran structures their internet infrastructure. It discusses the incentives developed over many years as well as some of the underlying assumptions that are being challenged.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Building a Live BGP Map</title>
  <link>https://kmcd.dev/posts/live-internet-map/</link>
  <pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate>
  <author>Kevin McDonald</author>
  <guid>https://kmcd.dev/posts/live-internet-map/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Nice introduction to BGP and a fun live video of a map showing BGP announcements!</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Nice introduction to BGP and a fun live video of a map showing BGP announcements!</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: The Self-Cancelling Subscription</title>
  <link>https://predr.ag/blog/the-self-cancelling-subscription/</link>
  <pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate>
  <author>Predrag Gruevski</author>
  <guid>https://predr.ag/blog/the-self-cancelling-subscription/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Distributed systems are difficult to get 100% right. This article goes over a recent story where it went wrong.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Distributed systems are difficult to get 100% right. This article goes over a recent story where it went wrong.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Development AI vs Runtime AI</title>
  <link>https://www.grepular.com/Development_AI_vs_Runtime_AI</link>
  <pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate>
  <author>Mike Cardwell</author>
  <guid>https://www.grepular.com/Development_AI_vs_Runtime_AI</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>It&rsquo;s not too surprising to me that Tesla would use LLMs to present a &ldquo;human readable summary&rdquo; to describe a fixed set of inputs. Add that to the lessons learned bucket.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>It&rsquo;s not too surprising to me that Tesla would use LLMs to present a &ldquo;human readable summary&rdquo; to describe a fixed set of inputs. Add that to the lessons learned bucket.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Quietly quantum-resistant blogging</title>
  <link>https://alexwlchan.net/2026/post-quantum-blog/</link>
  <pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate>
  <author>Alex Chan</author>
  <guid>https://alexwlchan.net/2026/post-quantum-blog/</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Public service announcement to make sure you&rsquo;re using post-quantum cryptography. Alex shares <a href="https://radar.cloudflare.com/post-quantum">Cloudflare&rsquo;s tool</a> which lets you know if a webserver supports post-quantum TLS key exchange. Also don&rsquo;t forget to check your SSH server! Luckily, many recent versions of these softwares ship with this enabled out of the box.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>Public service announcement to make sure you&rsquo;re using post-quantum cryptography. Alex shares <a href="https://radar.cloudflare.com/post-quantum">Cloudflare&rsquo;s tool</a> which lets you know if a webserver supports post-quantum TLS key exchange. Also don&rsquo;t forget to check your SSH server! Luckily, many recent versions of these softwares ship with this enabled out of the box.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Postroll: Security Advisories and Cognitive Overload</title>
  <link>https://www.hendrik-erz.de/post/security-advisories-and-cognitive-overload</link>
  <pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate>
  <author>Hendrik Erz</author>
  <guid>https://www.hendrik-erz.de/post/security-advisories-and-cognitive-overload</guid>
  <description><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I&rsquo;m thankful to all the open-source maintainers out there that work hard to ensure that the applications we use are polished and secure. It&rsquo;s always been a lot of work, but I do worry about the amount of &ldquo;vulnerabilities&rdquo; filed against these repositories today. Unfortunately (as Hendrik describes), every author believes their issue critical. Even if an act of God would have to occur for their vulnerability to be exploited.</p>]]></description>
  <content:encoded><![CDATA[<p><strong>Brandon's Comment:</strong></p><p>I&rsquo;m thankful to all the open-source maintainers out there that work hard to ensure that the applications we use are polished and secure. It&rsquo;s always been a lot of work, but I do worry about the amount of &ldquo;vulnerabilities&rdquo; filed against these repositories today. Unfortunately (as Hendrik describes), every author believes their issue critical. Even if an act of God would have to occur for their vulnerability to be exploited.</p>
]]></content:encoded>
</item>
  
  <item>
  <title>Filtering Goals of Necessity-Optimal Agents in Qualitative Possibilistic Recognition via Planning</title>
  <link>https://brandonrozek.com/paper/2603.01/</link>
  <pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/paper/2603.01/</guid>
  <description><![CDATA[]]></description>
  <content:encoded><![CDATA[]]></content:encoded>
  
</item>
  
  <item>
  <title>VSPursuer: A Tool for Finding Matrices Witnessing the Variable Sharing Property</title>
  <link>https://brandonrozek.com/paper/2602.01/</link>
  <pubDate>Mon, 09 Feb 2026 00:00:00 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/paper/2602.01/</guid>
  <description><![CDATA[]]></description>
  <content:encoded><![CDATA[]]></content:encoded>
  
</item>
  
  <item>
  <title>Bringing this website to the Tor network</title>
  <link>https://brandonrozek.com/blog/on-the-tor-network/</link>
  <pubDate>Sun, 01 Feb 2026 17:47:15 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/on-the-tor-network/</guid>
  <description><![CDATA[<p>I believe in the freedom of information. By making my website available as a Tor hidden service, you can be sure to access the information even if it&rsquo;s blocked on the clearweb.</p>
<p>In this post, I&rsquo;ll share the steps I took and what I learned along the way. Huge credit to Christian <a href="https://cleberg.net/blog/self-hosting-tor.html">who wrote their own succinct version</a> of this post and helped me troubleshoot via email.</p>
<h3 id="getting-an-address">Getting an Address</h3>
<p>Unlike the clear web, we don&rsquo;t register a domain with anyone. <a href="https://github.com/torproject/torspec/blob/main/rend-spec-v3.txt">An address on Tor is a hash of your public key.</a></p>]]></description>
  <content:encoded><![CDATA[<p>I believe in the freedom of information. By making my website available as a Tor hidden service, you can be sure to access the information even if it&rsquo;s blocked on the clearweb.</p>
<p>In this post, I&rsquo;ll share the steps I took and what I learned along the way. Huge credit to Christian <a href="https://cleberg.net/blog/self-hosting-tor.html">who wrote their own succinct version</a> of this post and helped me troubleshoot via email.</p>
<h3 id="getting-an-address">Getting an Address</h3>
<p>Unlike the clear web, we don&rsquo;t register a domain with anyone. <a href="https://github.com/torproject/torspec/blob/main/rend-spec-v3.txt">An address on Tor is a hash of your public key.</a></p>
<p>This is why onion URLs are long and unreadable. Take a look at the following onion URL which takes you to the Tor homepage.</p>
<pre tabindex="0"><code>http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion
</code></pre><p>Notice that the address starts with <code>http</code>. Unlike the clearweb, <em>all</em> traffic via Tor is encrypted. Our hidden service will use the public key behind the URL during the protocol exchange.</p>
<p>The primary benefit of these addresses are that they are entirely decentralized (we create our own keys). They are also incredibly difficult to spoof. The downside is that they are not human readable. We can get a little bit closer to that though with vanity keys.</p>
<p>To create a vanity key, we can use <a href="https://github.com/cathugger/mkp224o#requirements">mkp224o</a>. After following the installation instructions, we can create our own onion URL starting with some <code>prefix</code> by running the following:</p>
<pre tabindex="0"><code>./mkp224o prefix
</code></pre><p>Given the current algorithms and hardware, we can easily generate keys which have a URL prefix length of up to six characters in minutes. Beyond that, the generation quickly becomes infeasible&hellip; However, we want this to be the case. If it was feasible to generate a key-pair for an entire onion URL then anyone can spoof your hidden service.</p>
<p>The script by default will create many folders in the current directory which have the onion URLs as their name and the contents of the keys within. Pick your favorite and copy it over to <code>/var/lib/tor/somehiddenservicename</code>.</p>
<p><strong>While you&rsquo;re at it, make a backup of these keys because if we lose it then we lose control over the domain.</strong></p>
<h2 id="installing-and-configuring-tor">Installing and Configuring Tor</h2>
<p>Now that we have our address. Let&rsquo;s get Tor installed and set up. In this guide, I&rsquo;ll show how to do so via Podman Quadlets.</p>
<p>We&rsquo;ll use the Docker container <a href="https://github.com/dockur/tor">dockur/tor</a>. Since Tor is a cryptographic software, we need to be extremely careful where we download it from. As of this time of writing if we look at the Dockerfile in this repo, we can see that this is a simple wrapper over Alpine&rsquo;s tor packages. Currently, I feel that it&rsquo;s safe to trust the Alpine maintainers.</p>
<p>After selecting the Docker container, we need to write the Quadlet definition file. Here&rsquo;s how I have <code>/etc/containers/systemd/tor.container</code> configured:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Container]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ContainerName</span><span style="color:#f92672">=</span><span style="color:#e6db74">tor</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">HostName</span><span style="color:#f92672">=</span><span style="color:#e6db74">tor</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Image</span><span style="color:#f92672">=</span><span style="color:#e6db74">docker.io/dockurr/tor</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">AutoUpdate</span><span style="color:#f92672">=</span><span style="color:#e6db74">registry</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Volume</span><span style="color:#f92672">=</span><span style="color:#e6db74">/etc/tor:/etc/tor:ro,Z</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Volume</span><span style="color:#f92672">=</span><span style="color:#e6db74">/var/lib/tor:/var/lib/tor:Z,U</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Restart</span><span style="color:#f92672">=</span><span style="color:#e6db74">always</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">default.target</span>
</span></span></code></pre></div><p>Before we run it, we&rsquo;ll need to create our main Tor configuration file. This lives in <code>/etc/tor/torrc</code>.</p>
<pre tabindex="0"><code>SocksPort 0
HiddenServiceDir /var/lib/tor/somehiddenservicename
HiddenServicePort 80 IP_OF_NGINX_CONTAINER:80
</code></pre><p>Replace <code>IP_OF_NGINX_CONTAINER</code> with the internal IP of the Nginx container or of the host machine if it is running on there. Here&rsquo;s an explanation of each line:</p>
<table>
  <thead>
      <tr>
          <th>Key</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SocksPort</code></td>
          <td>By default, the tor service will open a SOCKS port so that we can have other HTTP clients proxy to the dark web. Setting this to 0 disables that behavior.</td>
      </tr>
      <tr>
          <td><code>HiddenServiceDir</code></td>
          <td>The location of our hostname file and the keys for our hidden service.</td>
      </tr>
      <tr>
          <td><code>HiddenServicePort</code></td>
          <td>The port on the Tor network followed by the <code>address:port</code> to proxy the traffic to. We may have multiple of these per hidden service.</td>
      </tr>
  </tbody>
</table>
<p>Since we&rsquo;re proxying traffic to our application, let&rsquo;s configure our target next.</p>
<h2 id="configuring-nginx">Configuring Nginx</h2>
<p>My website is a static site served by Nginx. These files are linked together via absolute URLs. This adds some complications because the onion hidden service URL is completely different than my clearnet one.</p>
<p>One solution would be to make my URLs relative. However, I want my website to be as portable as possible. For example, you can run my website in a subfolder. Thus, our solution here is to have an entirely separate copy of the website where the only difference is the internal URLs.</p>
<p>I&rsquo;ve set this version of my website to live in <code>/var/www/brozekhs</code>.</p>
<p>As such, we have to create an Nginx configuration file solely for our hidden service. We&rsquo;ll make sure to point the root to the folder which contains the hidden service version of my website.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">listen</span> <span style="color:#ae81ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">server_name</span> <span style="color:#e6db74">ONION_URL</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#f92672">root</span> <span style="color:#e6db74">/var/www/brozekhs</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">index</span> <span style="color:#e6db74">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>	<span style="color:#75715e"># ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>	<span style="color:#f92672">location</span> <span style="color:#e6db74">/</span> {
</span></span><span style="display:flex;"><span>		<span style="color:#f92672">allow</span> <span style="color:#e6db74">IP_OF_TOR_CONTAINER</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">deny</span> <span style="color:#e6db74">all</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>	}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Replace <code>/var/www/brozekhs</code>, <code>ONION_URL</code> and <code>IP_OF_TOR_CONTAINER</code> with their respective values. We need to configure the <code>allow</code> and <code>deny</code> lines because we don&rsquo;t want someone to confirm that our machine responds to tor addresses over the clearnet.</p>
<p>After this when running my <code>nginx</code> container I came across the following error:</p>
<pre tabindex="0"><code>date time [emerg] 1#1: could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
</code></pre><p>As stated, the solution is to increase the server name hash bucket size. I doubled it from 64 to 128 in the <code>http</code> block of <code>/etc/nginx/nginx.conf</code>:</p>
<pre tabindex="0"><code>server_names_hash_bucket_size 128;
</code></pre><p>Lastly if you have a clearnet site and you want to advertise the onion URL for users visiting the clearnet URL on the Tor browser, then we can add an <a href="https://community.torproject.org/onion-services/advanced/onion-location/"><code>Onion-Location</code> header</a> to the Nginx config of our clearnet site.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">location</span> <span style="color:#e6db74">/</span> {
</span></span><span style="display:flex;"><span>	<span style="color:#75715e"># ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Onion-Location</span> <span style="color:#e6db74">ONION_URL</span>$request_uri;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Replace <code>ONION_URL</code> with your own and make sure that <code>$request_uri</code> stays at the end of the header value. This will help the Tor browser with the redirection.</p>
<h2 id="start-em-up">Start em up!</h2>
<p>With that, we can start our two services:</p>
<pre tabindex="0"><code>sudo systemctl start nginx
sudo systemctl start tor
</code></pre><p>When we look at <code>journalctl -U tor</code>, we should see something like the following:</p>
<pre tabindex="0"><code>datetime hostname systemd[1]: Started tor.service.
datetime hostname tor[681220]: datetime [notice] Tor can&#39;t help you if you use it wrong! Learn how to be safe at https://support.torproject.org/faq/staying-anonymous/
datetime hostname tor[681220]: datetime [notice] Read configuration file &#34;/etc/tor/torrc&#34;.
datetime hostname tor[681220]: datetime [notice] Parsing GEOIP IPv4 file /usr/share/tor/geoip.
datetime hostname tor[681220]: datetime [notice] Parsing GEOIP IPv6 file /usr/share/tor/geoip6.
datetime hostname tor[681220]: datetime [notice] Bootstrapped 0% (starting): Starting
datetime hostname tor[681220]: datetime [notice] Starting with guard context &#34;default&#34;
datetime hostname tor[681220]: datetime [notice] Bootstrapped 5% (conn): Connecting to a relay
datetime hostname tor[681220]: datetime [notice] Bootstrapped 10% (conn_done): Connected to a relay
datetime hostname tor[681220]: datetime [notice] Bootstrapped 14% (handshake): Handshaking with a relay
datetime hostname tor[681220]: datetime [notice] Bootstrapped 15% (handshake_done): Handshake with a relay done
datetime hostname tor[681220]: datetime [notice] Bootstrapped 45% (requesting_descriptors): Asking for relay descriptors
datetime hostname tor[681220]: datetime [notice] Bootstrapped 50% (loading_descriptors): Loading relay descriptors
datetime hostname tor[681220]: datetime [notice] Bootstrapped 55% (loading_descriptors): Loading relay descriptors
datetime hostname tor[681220]: datetime [notice] Bootstrapped 60% (loading_descriptors): Loading relay descriptors
datetime hostname tor[681220]: datetime [notice] Bootstrapped 66% (loading_descriptors): Loading relay descriptors
datetime hostname tor[681220]: datetime [notice] Bootstrapped 75% (enough_dirinfo): Loaded enough directory info to build circuits
datetime hostname tor[681220]: datetime [notice] Bootstrapped 90% (ap_handshake_done): Handshake finished with a relay to build circuits
datetime hostname tor[681220]: datetime [notice] Bootstrapped 95% (circuit_create): Establishing a Tor circuit
datetime hostname tor[681220]: datetime [notice] Bootstrapped 100% (done): Done
</code></pre><p>We won&rsquo;t be able to access the hidden service until the bootstrapping process reaches 100%. Depending on the state of the network, this might take awhile&hellip;</p>
<p>I mention the network state since it&rsquo;s common for me to see this in the logs:</p>
<pre tabindex="0"><code>datetime hostname tor[295456]: datetime [warn] Detected possible compression bomb with input size = 17413 and output size = 515472 (compression factor = 29.60)
datetime hostname tor[295456]: datetime [warn] Possible compression bomb; abandoning stream.
datetime hostname tor[295456]: datetime [warn] Detected possible compression bomb with input size = 23289 and output size = 774624 (compression factor = 33.26)
datetime hostname tor[295456]: datetime [warn] Possible compression bomb; abandoning stream.
</code></pre><p>This compression or <a href="https://en.wikipedia.org/wiki/Zip_bomb">zip bomb</a> appears to be an attempt at a distributed <a href="https://en.wikipedia.org/wiki/Denial-of-service_attack">denial of service attack</a>. Luckily, the Tor software is smart enough to guard against this.</p>
<p>Every so often my Tor service goes down and I need to restart the daemon for it to come back up. I haven&rsquo;t gotten around to writing monitoring scripts for my hidden service so if anyone has any tips feel free to get in touch.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Tales of Christmas Trees</title>
  <link>https://brandonrozek.com/blog/tales-of-christmas-trees/</link>
  <pubDate>Sun, 25 Jan 2026 17:59:15 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/tales-of-christmas-trees/</guid>
  <description><![CDATA[<p>In 2020, Clare and I got our first live Christmas tree. We were living in Virginia at the time, and we showed up to a farm that had many trees planted in rows. They gave us a bow saw, and it was up to us to chop one down and bring it home.</p>
<p><img src="/files/images/blog/transportingtree2020.jpg" alt="Brandon hauling his tree in a cart."></p>
<p>I enjoyed having a live tree in my apartment. I still remember the smell of fresh conifer. After several weeks, the holidays pass and the needles start falling to the ground. I don&rsquo;t quite remember how we got rid of this tree, but I wouldn&rsquo;t be surprised if we just threw it in our dumpster.</p>]]></description>
  <content:encoded><![CDATA[<p>In 2020, Clare and I got our first live Christmas tree. We were living in Virginia at the time, and we showed up to a farm that had many trees planted in rows. They gave us a bow saw, and it was up to us to chop one down and bring it home.</p>
<p><img src="/files/images/blog/transportingtree2020.jpg" alt="Brandon hauling his tree in a cart."></p>
<p>I enjoyed having a live tree in my apartment. I still remember the smell of fresh conifer. After several weeks, the holidays pass and the needles start falling to the ground. I don&rsquo;t quite remember how we got rid of this tree, but I wouldn&rsquo;t be surprised if we just threw it in our dumpster.</p>
<p>We later moved to New York. For the next few years, we would travel to see friends and family during the holidays. I used that as an excuse to not have a tree setup at home. Eventually Clare convinces me that we should still be festive at home, and we bought a plastic tree.</p>
<p>Around us in upstate New York, there are several bonfire events in January. There&rsquo;s one in particular that we&rsquo;ve attended for several New Year&rsquo;s Eve. What&rsquo;s special about this bonfire is that the fuel of the fire is not prepared as chopped wood in advance. Instead, it is sourced from the community.</p>
<p>Let me paint a picture. It&rsquo;s a cold winter night, and there is a group of around 20-30 people bundled up in full winter gear staying close to the bonfire. As time progresses, the fire gets smaller and smaller, and we get closer to try to feel some of its heat.  Then, a person in a pickup truck arrives, gets out of their vehicle, and chucks their Christmas tree from the bed of the truck onto the fire. The fire rages.</p>
<p><img src="/files/images/blog/bonfire2025.jpg" alt="Christmas tree burning in a bonfire"></p>
<p>Now I find this very cool, but some trees have different fates. Ton writes on <a href="https://www.zylstra.org/blog/2026/01/not-the-elves/">his blog</a> that they have the same tree every year. Instead of sawing their tree down, the tree gets delivered to their place with the root ball intact. Once the holidays are over, the growers come back and pick it up.</p>
<p>Through an email exchange, I learn that this is a <a href="https://adopteereenkerstboom.nl/">national service</a> over in the Netherlands. Now that&rsquo;s pretty cool.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Blogging as an Invitation for Dialogue</title>
  <link>https://brandonrozek.com/blog/blogging-as-dialogue-invitation/</link>
  <pubDate>Thu, 15 Jan 2026 11:21:29 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/blogging-as-dialogue-invitation/</guid>
  <description><![CDATA[<p>We have many ways to share ideas today. We can:</p>
<ul>
<li>Text</li>
<li>Email</li>
<li>Pen a letter</li>
<li>Post on a microblog (Mastodon/X/Pixelfed/etc.)</li>
<li>Write a blog post</li>
</ul>
<p>But not all of these methods inherently create a conversation or dialogue. When I write a technical blog post, I don&rsquo;t expect a reply. Similarly, when I toot on Mastodon, I&rsquo;m fine if no one favorited the post. As such, (micro-)blogging differs greatly from texting and calling someone and is instead much closer to recording a postcast or uploading a video &ndash; a one-way transmission of information.</p>]]></description>
  <content:encoded><![CDATA[<p>We have many ways to share ideas today. We can:</p>
<ul>
<li>Text</li>
<li>Email</li>
<li>Pen a letter</li>
<li>Post on a microblog (Mastodon/X/Pixelfed/etc.)</li>
<li>Write a blog post</li>
</ul>
<p>But not all of these methods inherently create a conversation or dialogue. When I write a technical blog post, I don&rsquo;t expect a reply. Similarly, when I toot on Mastodon, I&rsquo;m fine if no one favorited the post. As such, (micro-)blogging differs greatly from texting and calling someone and is instead much closer to recording a postcast or uploading a video &ndash; a one-way transmission of information.</p>
<p>Ploum wrote about how he views the <a href="https://ploum.net/2025-12-15-communication-entertainment.html">ActivityPub protocol as a conversation</a>, and as such servers should not filter posts based on <a href="https://ploum.net/2025-12-04-pixelfed-against-fediverse.html">whether they have a picture</a>. Now I&rsquo;m not on Pixelfed, so I do not have a stake in this issue. However since I view Pixelfed as a microblogging platform, I tend to see it as more of a one-way transmission of information rather than soliciting a response from my friends. This view would put me in the category of folks that view AcitvityPub as a &ldquo;content consumption platform.&rdquo; Though I find that term derogative<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p>
<p>Now should Pixelfed filter posts based on whether it contains a picture? I&rsquo;m not sure. But if I was on Mastodon and I specifically @&rsquo;d  someone on Pixelfed,  then I would sure hope that either they received that message or I was shown an error.</p>
<p>So if we&rsquo;re not having a conversation with these (micro-)blog posts, what are we doing? Some of us are trying to teach, keep a public journal, or share our perspectives.  Some of us don&rsquo;t even want responses<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>. Though I find that many of us do:</p>
<blockquote>
<p>Blog posts become invitations that never expire
- <a href="https://dri.es/20-years-of-blogging">Dries</a></p></blockquote>
<p>When I send a postcard to a loved one, they don&rsquo;t need to reply. However, I send it because it&rsquo;s an acknowledgement of our relationship and it&rsquo;s an invitation to reach out.</p>
<p>Similarly when I write a blog post, by default I&rsquo;m only transmitting information. However, any of you readers can choose to promote this from a transmission to an exchange. From a communication to a dialogue.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>The idea that folks are doom-scrolling and are only consuming empty calories misses how communities are  formed on these platforms &ndash; complete with their own social norms.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Every so often I get sad when I want to reply to someone, but I don&rsquo;t see an email to reach out to.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
  
</item>
  
  <item>
  <title></title>
  <link></link>
  <pubDate>Sun, 11 Jan 2026 23:16:43 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid></guid>
  <description><![CDATA[<p>Wikipedia turns 25 this upcoming Thursday 🎂 🎈 🎉 </p><p><a href="https://meta.wikimedia.org/wiki/Event:Wikipedia_25_Virtual_Celebration" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://</span><span class="ellipsis">meta.wikimedia.org/wiki/Event:</span><span class="invisible">Wikipedia_25_Virtual_Celebration</span></a></p><p>Here&#39;s to another 25 years!</p>]]></description>
  <content:encoded><![CDATA[<p>Wikipedia turns 25 this upcoming Thursday 🎂 🎈 🎉 </p><p><a href="https://meta.wikimedia.org/wiki/Event:Wikipedia_25_Virtual_Celebration" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://</span><span class="ellipsis">meta.wikimedia.org/wiki/Event:</span><span class="invisible">Wikipedia_25_Virtual_Celebration</span></a></p><p>Here&#39;s to another 25 years!</p>]]></content:encoded>
  
</item>
  
  <item>
  <title>Backing up my data with Restic, Btrfs, and MinIO</title>
  <link>https://brandonrozek.com/blog/backups-with-restic-btrfs/</link>
  <pubDate>Tue, 30 Dec 2025 11:51:07 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/backups-with-restic-btrfs/</guid>
  <description><![CDATA[<p>For the past year, I settled on a backup strategy that serves my needs. In this post, I&rsquo;ll share the properties I look for in a backup solution and how my current solution addresses them. As always, if you have any suggestions or improvements, feel free to get in touch.</p>
<p>The first step before talking technology is to identify exactly what data we want to backup. In my homelab, I rely on <a href="https://immich.app/">Immich</a> for photo storage, <a href="https://www.navidrome.org/">Navidrome</a> for music streaming, databases for my website, and other personal documents. This amounts to a little over 250 GB of data that would be very difficult for me to replace if it was lost.</p>]]></description>
  <content:encoded><![CDATA[<p>For the past year, I settled on a backup strategy that serves my needs. In this post, I&rsquo;ll share the properties I look for in a backup solution and how my current solution addresses them. As always, if you have any suggestions or improvements, feel free to get in touch.</p>
<p>The first step before talking technology is to identify exactly what data we want to backup. In my homelab, I rely on <a href="https://immich.app/">Immich</a> for photo storage, <a href="https://www.navidrome.org/">Navidrome</a> for music streaming, databases for my website, and other personal documents. This amounts to a little over 250 GB of data that would be very difficult for me to replace if it was lost.</p>
<p>Not all my data lives on that one server though. I also have a storage VPS which runs <a href="https://nextcloud.com/">Nextcloud</a> and <a href="https://hedgedoc.org/">Hedgedoc</a>. Both of those services combined have less than 100 GB of data, but just like the homelab, that data is precious. The storage VPS has a total capacity of 1.5 TB.</p>
<p>The <a href="https://www.backblaze.com/blog/the-3-2-1-backup-strategy/">3-2-1 backup strategy</a> coined by Peter Krogh suggests that we should have three copies of our data, stored on two different media, and with one being off-site.</p>
<p>Both of my servers have enough storage capacity to hold a copy of all of my data. For my third copy, I decided to rely on <a href="https://www.backblaze.com/cloud-storage">Backblaze b2</a>. This is a S3 object storage service that charges low rates for what I store and the number of transactions I make.</p>
<p>My homelab sits in my house, the storage VPS sits in Montreal, and Backblaze stores my data in Phoenix. Therefore, I have <strong>3</strong> copies of my data and it&rsquo;s stored in more than <strong>1</strong> location.</p>
<p>In order to address having at least <strong>2</strong> different storage media, I backup my data using two different approaches: Restic and Btrfs Snapshots. I don&rsquo;t currently have enough data such that hot online storage is cost-prohibitive.</p>
<h3 id="restic">Restic</h3>
<p><a href="https://restic.net/">Restic</a> is a modern and open-source backup software that both deduplicates and encrypts file contents at the blob level. In combination with a metadata local cache, this makes this Restic snappy and efficient to use.</p>
<p>It supports a variety of backup targets:</p>
<ul>
<li>Local filesystem</li>
<li>SFTP</li>
<li>REST server</li>
<li>S3</li>
<li>and <a href="https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#">several others</a>, including those implemented by <a href="https://rclone.org/">Rclone</a>!</li>
</ul>
<p>While I could use the variety of backup targets to increase the number of approaches my data is backed up, to me, this does not feel sufficiently different enough to warrent the additional complexity. Therefore, I&rsquo;ll use one backup target for all three locations.</p>
<p>Since Backblaze b2 only supports the S3 protocol, this means then that my choice has been made for me. However even if it wasn&rsquo;t forced upon me, I would still pick that option. Designed by Amazon for their storage service, the S3 protocol is built with scale in mind and supports concurrent multipart file uploads. Additionally unlike SFTP, S3 separates its accounts from that of the server. There are ways to restrict SSH clients, however, it is not the default behavior. In S3, by default a user cannot do anything.</p>
<p>Thus, after choosing S3, I need to set up a S3 server on both my storage VPS and my homelab. I landed on MinIO for its ease of setup and longevity, however, any of the other solutions would work as well.</p>
<p>Since, we&rsquo;re storing copies of the data on all the servers, one idea is to set it up in a cluster configuration. However, since I&rsquo;m not managing the S3 server hosted by Backblaze, I wouldn&rsquo;t be able to include that in the cluster. Generally, it is not recommended to run a cluster with only two nodes. If we do, it can lead to what&rsquo;s called a <a href="https://en.wikipedia.org/wiki/Split-brain_(computing)"><em>split brain problem</em></a> where both nodes are unable to communicate with each other and think that they are the leader node.</p>
<p>To avoid this, we can run both MinIO instances inpendently and have Restic separately send the data to all servers.</p>
<h4 id="minio-setup">MinIO Setup</h4>
<p>If you already have a bucket setup and configured with the appropriate permissions, then feel free to skip this section.</p>
<p>The following code examples assume that <a href="https://min.io/docs/minio/container/index.html">MinIO is installed</a> and the <code>mc</code> client is available with the root credentials to each server (alias) set. These general steps can also be achieved using the management UI. For simplicity, I&rsquo;ll show how to do this with respect to one server <code>homelab</code>. However, we&rsquo;ll need to do this for every MinIO instance.</p>
<p>First, <a href="https://min.io/docs/minio/linux/reference/minio-mc/mc-mb.html">create a bucket</a> which will hold our backup data.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># alias_of_server/bucket_name</span>
</span></span><span style="display:flex;"><span>mc mb homelab/backups
</span></span></code></pre></div><p>Afterwards, create a <a href="https://min.io/docs/minio/linux/administration/identity-access-management/minio-user-management.html">backup user</a>. We&rsquo;ll need to specify the <code>ACCESS_KEY</code> and <code>SECRET_KEY</code> which corresponds to the username and password respectively. Store these as we&rsquo;ll later need to use them.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;my_awesome_homelab_user&#34;</span>
</span></span><span style="display:flex;"><span>export SECRET_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;SUPER_SECURE_SECRET_KEY&#34;</span>
</span></span><span style="display:flex;"><span>mc admin user add homelab $ACCESSKEY $SECRETKEY
</span></span></code></pre></div><p><em>Note:</em> For a bit more security, I like to create a different access key and secret key for the other servers.</p>
<p>By default, our user cannot cannot do anything. Let&rsquo;s make it so that they have access to our <code>backups</code> bucket we created earlier. To do that, we&rsquo;ll need to create a <a href="https://min.io/docs/minio/linux/administration/identity-access-management/policy-based-access-control.html">policy</a> which allows the user to perform operations on that bucket.</p>
<p>We&rsquo;ll have to create a JSON file and follow the AWS IAM format.</p>
<pre tabindex="0"><code class="language-iam" data-lang="iam">{
 &#34;Version&#34;: &#34;2012-10-17&#34;,
 &#34;Statement&#34;: [
  {
   &#34;Effect&#34;: &#34;Allow&#34;,
   &#34;Action&#34;: [
    &#34;s3:ListBucket&#34;,
    &#34;s3:PutObject&#34;,
    &#34;s3:DeleteObject&#34;,
    &#34;s3:GetObject&#34;
   ],
   &#34;Resource&#34;: [
    &#34;arn:aws:s3:::backups/*&#34;,
    &#34;arn:aws:s3:::backups&#34;
   ]
  }
 ]
}
</code></pre><p>Assume that we saved the prior JSON at a location specified by the environmental variable <code>$POLICY_PATH</code>, then we can create the policy through the following:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export POLICY_NAME<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;backup-policy&#34;</span>
</span></span><span style="display:flex;"><span>mc admin policy create homelab $POLICY_NAME $POLICY_PATH
</span></span></code></pre></div><p>After creating the policy, we need to assign it to our backup user.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mc admin policy attach homelab $POLICY_NAME --user $ACCESS_KEY
</span></span></code></pre></div><h4 id="nginx-and-initializing-restic">Nginx and Initializing Restic</h4>
<p>With our bucket and user configured, now let&rsquo;s discuss how we&rsquo;ll perform our backups. Our bucket will have the following directory structure:</p>
<pre tabindex="0"><code>backups/
  homelab/
  vps/
</code></pre><p>Each folder will contain a restic repository. We separate them out instead of having one giant restic repository, so that we don&rsquo;t have to worry about multiple servers competing for a lock.</p>
<p>Instead of communicating over HTTP, we want a secure way to access our buckets from outside the server. For this, I use <a href="https://min.io/docs/minio/linux/integrations/setup-nginx-proxy-with-minio.html">nginx to proxy the traffic over to MinIO</a>. I configure <a href="https://letsencrypt.org/getting-started/">Certbot with LetsEncrypt</a> so that the connection can be secureted with HTTPS/TLS. In addition to the instructions listed on the MinIO page, I also restrict the IPs which are allowed to connect. For example, to only allow traffic from within your Wireguard network (ex subnet: <code>10.10.10.1/24</code>) then you can put the following within the <code>location</code> block of the nginx config.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">allow</span> <span style="color:#ae81ff">10</span><span style="color:#e6db74">.10.10.1/24</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">deny</span> <span style="color:#e6db74">all</span>; <span style="color:#66d9ef">//</span> <span style="color:#e6db74">Denies</span> <span style="color:#e6db74">all</span> <span style="color:#e6db74">other</span> <span style="color:#e6db74">traffic</span>
</span></span></code></pre></div><p>After securing the entryway to our S3 server, we&rsquo;re ready to use Restic. Before jumping in, you might want to <a href="https://restic.readthedocs.io/en/stable/080_examples.html#full-backup-without-root">create a dedicated account</a> so that we don&rsquo;t have to use the <code>root</code>  user.</p>
<p>Let&rsquo;s initialize each server&rsquo;s respective repository. Since our backups will be encrypted, we need to come up with another password and put it within the <code>RESTIC_PASSWORD</code> environmental variable. Save this password in a safe place, since we will not be able to decrypt the backup without it.</p>
<pre tabindex="0"><code>export RESTIC_REPOSITORY=s3:https://&lt;domain-of-s3-server&gt;/backups/homelab
export AWS_ACCESS_KEY_ID=$ACCESS_KEY
export AWS_SECRET_ACCESS_KEY=$SECRET_ACCESS_KEY
export RESTIC_PASSWORD=&#34;RESTIC_SUPER_SECURE_PASSWORD&#34;

restic init
</code></pre><p>From here, create a backup script at <code>/usr/local/bin/backup.sh</code>. This is where we specify which folders to backup.</p>
<p>Example:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>set -o errexit
</span></span><span style="display:flex;"><span>set -o nounset
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span>$EUID<span style="color:#e6db74">&#34;</span> -ne <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>id -u restic<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">then</span> echo <span style="color:#e6db74">&#34;Please run as restic&#34;</span>
</span></span><span style="display:flex;"><span>  exit
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Usage: rbackup [message] [restic-tag] [backup-dir]</span>
</span></span><span style="display:flex;"><span>rbackup <span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  restic backup <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --tag <span style="color:#e6db74">&#34;</span>$2<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    <span style="color:#e6db74">&#34;</span>$3<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## BACKUP TO CLOUD</span>
</span></span><span style="display:flex;"><span>export RESTIC_REPOSITORY<span style="color:#f92672">=</span>s3:https://&lt;domain-of-cloud-server&gt;/backups/homelab
</span></span><span style="display:flex;"><span>export AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;CLOUD_ACCESS_KEY&#34;</span>
</span></span><span style="display:flex;"><span>export AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;CLOUD_SECRET_ACCESS_KEY&#34;</span>
</span></span><span style="display:flex;"><span>export RESTIC_PASSWORD<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;RESTIC_SUPER_SECURE_PASSWORD&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>rbackup <span style="color:#e6db74">&#34;Backing up documents&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  Documents <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  /home/user/Documents
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Backup other great directories...</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## BACKUP TO BACKBLAZE</span>
</span></span><span style="display:flex;"><span>export RESTIC_REPOSITORY<span style="color:#f92672">=</span>s3:https://&lt;domain-of-backblaze-server&gt;/backups/homelab
</span></span><span style="display:flex;"><span>export AWS_ACCESS_KEY_ID<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;BACKBLAZE_ACCESS_KEY&#34;</span>
</span></span><span style="display:flex;"><span>export AWS_SECRET_ACCESS_KEY<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;BACKBLAZE_SECRET_ACCESS_KEY&#34;</span>
</span></span><span style="display:flex;"><span>export RESTIC_PASSWORD<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;RESTIC_SUPER_SECURE_PASSWORD&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>rbackup <span style="color:#e6db74">&#34;Backing up documents&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  Documents <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  /home/user/Documents
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Backup other great directories...</span>
</span></span></code></pre></div><p>Unless you have unlimited storage, you probably want to prune old backups according to some schedule. In the same script I have the following function:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>prune <span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>  echo <span style="color:#e6db74">&#34;Pruning old snapshots&#34;</span>
</span></span><span style="display:flex;"><span>  restic unlock
</span></span><span style="display:flex;"><span>  restic forget <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --group-by <span style="color:#e6db74">&#34;tags&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --keep-daily N_DAYS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --keep-weekly N_WEEKS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --keep-monthly N_MONTHS <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --prune
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></div><p>Replace <code>N_*</code> to your liking. Restic will then ensure that it keeps enough snapshots such that those time-based rules are satisfied.</p>
<p>With the script written, we can then setup a systemd service and timer so that it runs daily. Write the following to <code>/etc/systemd/system/restic-backup.service</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Description</span><span style="color:#f92672">=</span><span style="color:#e6db74">Executes backup script</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Requires</span><span style="color:#f92672">=</span><span style="color:#e6db74">network-online.target</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Wants</span><span style="color:#f92672">=</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">User</span><span style="color:#f92672">=</span><span style="color:#e6db74">restic</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Group</span><span style="color:#f92672">=</span><span style="color:#e6db74">restic</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Type</span><span style="color:#f92672">=</span><span style="color:#e6db74">oneshot</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecStart</span><span style="color:#f92672">=</span><span style="color:#e6db74">/usr/local/bin/backup.sh</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Environment</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;HOME=/home/restic&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">multi-user.target</span>
</span></span></code></pre></div><p>Write the timer to <code>/etc/systemd/system/restic-backup.timer</code></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Timer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">OnCalendar</span><span style="color:#f92672">=</span><span style="color:#e6db74">daily</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Persistent</span><span style="color:#f92672">=</span><span style="color:#e6db74">true</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">timers.target</span>
</span></span></code></pre></div><h3 id="btrfs-snapshots">Btrfs snapshots</h3>
<p>Both my servers run Btrfs as the underlying filesystem. One cool feature is that Btrfs can create <em>snapshots</em>. These are immutable point-in-time views of a given subvolume. As such, we&rsquo;ll need to create a subvolume that will contain the directories we want.</p>
<p>If we&rsquo;re not starting from scratch, and instead want to create a subvolume from an existing folder, then I recommend performing the following steps from <a href="https://www.reddit.com/r/btrfs/comments/198hbod/converting_directory_into_subvolume/">this reddit thread</a>:</p>
<pre tabindex="0"><code>mv folder folder_backup
btrfs subvolume create folder
cp --archive --one-file-system --reflink=always folder_backup/. folder
</code></pre><p>From here, we can create our snapshots. Like with our Restic setup, I like having some daily, weekly, and monthly backups available. I&rsquo;ll store these snapshots in <code>/snapshots</code>, but you&rsquo;re free to change the location. Here&rsquo;s what it looks like on my machine:</p>
<pre tabindex="0"><code>/snapshots/
  daily
    20251201
      /home/user/Documents
      ...
      /home/user/Music
    ...
    20251227
  monthly
    ...
  weekly
    ...
</code></pre><p>Since these are backup snapshots, we want it to be <em>readonly</em>. Here is what it looks like to create a daily snapshot for our documents subvolume:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>SNAPSHOT_PATH<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/snapshots/daily/</span><span style="color:#66d9ef">$(</span>date +<span style="color:#e6db74">&#39;%Y%m%d&#39;</span><span style="color:#66d9ef">)</span><span style="color:#e6db74">/home/user/Documents&#34;</span>
</span></span><span style="display:flex;"><span>mkdir -p <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>dirname $SNAPSHOT_PATH<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>btrfs subvolume snapshot -r /home/user/Documents <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>We used the date in the folder name so that we can easily detect the oldest snapshots for deletion.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>OLDEST_SNAPSHOT<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>ls <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> | sort | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> SUBVOLUME_PATH in <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SUBVOLUMES[@]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    SNAPSHOT_PATH<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>OLDEST_SNAPSHOT<span style="color:#e6db74">}${</span>SUBVOLUME_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    btrfs subvolume delete -c <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>    
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>rm -rf <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>OLDEST_SNAPSHOT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>I put together a script which takes as input the environment variable <code>SNAPSHOT_DIR</code> and handles creating and pruning snapshots. To get these snapshots at different time intervals, we use different systemd timers and change that input variable. Unlike with our restic setup, this will keep the same $N$ number of snapshots for each of our time intervals.  We copy this script to <code>/usr/local/bin/localbtrbak.sh</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>set -o nounset
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>show_usage<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#e6db74">&#34;Usage: localbtrback&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Check argument count</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span>$#<span style="color:#e6db74">&#34;</span> -ne <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    show_usage
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span>$EUID<span style="color:#e6db74">&#34;</span> -ne <span style="color:#ae81ff">0</span> <span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">then</span> echo <span style="color:#e6db74">&#34;Please run as root&#34;</span>
</span></span><span style="display:flex;"><span>    exit
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> -z <span style="color:#e6db74">&#34;</span>$SNAPSHOT_DIR<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">then</span> echo <span style="color:#e6db74">&#34;SNAPSHOT_DIR not defined&#34;</span>
</span></span><span style="display:flex;"><span>    exit
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># EDIT TO POINT TO YOUR SUBVOLUMES</span>
</span></span><span style="display:flex;"><span>SUBVOLUMES<span style="color:#f92672">=(</span><span style="color:#e6db74">&#34;/home/user/Documents&#34;</span>  <span style="color:#e6db74">&#34;/home/user/Music&#34;</span><span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> SUBVOLUME_PATH in <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SUBVOLUMES[@]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    SNAPSHOT_PATH<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#66d9ef">$(</span>date +<span style="color:#e6db74">&#39;%Y%m%d&#39;</span><span style="color:#66d9ef">)</span><span style="color:#e6db74">${</span>SUBVOLUME_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create folder if not already exists</span>
</span></span><span style="display:flex;"><span>    mkdir -p <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>dirname $SNAPSHOT_PATH<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Create the readonly snapshot</span>
</span></span><span style="display:flex;"><span>    btrfs subvolume snapshot -r <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SUBVOLUME_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Calculate the number of snapshots</span>
</span></span><span style="display:flex;"><span>COUNT<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>ls <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> | wc -l<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>COUNT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> -gt <span style="color:#ae81ff">3</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    OLDEST_SNAPSHOT<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>ls <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> | sort | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> SUBVOLUME_PATH in <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SUBVOLUMES[@]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>        SNAPSHOT_PATH<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>OLDEST_SNAPSHOT<span style="color:#e6db74">}${</span>SUBVOLUME_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        btrfs subvolume delete -c <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>    rm -rf <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>SNAPSHOT_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">${</span>OLDEST_SNAPSHOT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>For our systemd service in <code>/etc/systemd/system/btrlocalback@.service</code></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Description</span><span style="color:#f92672">=</span><span style="color:#e6db74">Create a local BTRFS snapshot</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Type</span><span style="color:#f92672">=</span><span style="color:#e6db74">oneshot</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Environment</span><span style="color:#f92672">=</span><span style="color:#e6db74">SNAPSHOT_DIR=&#34;/snapshots/%i&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecStart</span><span style="color:#f92672">=</span><span style="color:#e6db74">/usr/local/bin/localbtrbak.sh</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">multi-user.target</span>
</span></span></code></pre></div><p>An example daily timer stored in <code>/etc/systemd/system/btrlocalbak@daily.timer</code></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Description</span><span style="color:#f92672">=</span><span style="color:#e6db74">Create a daily local BTRFS snapshot</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Timer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">OnCalendar</span><span style="color:#f92672">=</span><span style="color:#e6db74">daily</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Persistent</span><span style="color:#f92672">=</span><span style="color:#e6db74">true</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">timers.target</span>
</span></span></code></pre></div><h3 id="conclusion">Conclusion</h3>
<p>Here is a visualization of the three servers and where the data gets backed up to:</p>
<p><img src="/files/images/blog/BackupSetup2025.svg" alt="Diagram of the backup setup I described"></p>
<p>The bucket image in the diagram denotes the S3 storage, while the cylinder database icon denotes the Btrfs snapshots. Pictorially, this also shows us how the 3-2-1 rule is satisfied.</p>
<ul>
<li>Three outgoing arrows on the two servers means that we have three copies of the data</li>
<li>The two icons on each of the servers show that we&rsquo;re backing it up in two different ways</li>
<li>I have more than one off-site backup since all three boxes are in different locations.</li>
</ul>
<p>If I accidentally delete a file, the Btrfs setup is useful since I can quickly access the old version at <code>/snapshots/daily/&lt;yesterday&gt;/path</code>. However, if my entire server goes down, then I can use one of the restic backups in either server to restore.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>After 75 Miles of Running</title>
  <link>https://brandonrozek.com/blog/running-75-miles/</link>
  <pubDate>Fri, 26 Dec 2025 09:55:41 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/running-75-miles/</guid>
  <description><![CDATA[<p>Back in August, I started running regularly and recording my sessions. I recently completed a total of 75 miles of running across all my sessions. In this post, I&rsquo;ll reflect on my journey so far and what I&rsquo;ve learned.</p>
<h2 id="the-beginning">The Beginning</h2>
<p>Over the summer, I lived and worked in Austin, Texas. For the prior months of the year, I&rsquo;ve lived a fairly sedimentary lifestyle. I enjoy these summers where I am forced to walk more places and <a href="/blog/exploring-via-public-transit/">take public transit</a> since I don&rsquo;t have access to a vehicle.</p>]]></description>
  <content:encoded><![CDATA[<p>Back in August, I started running regularly and recording my sessions. I recently completed a total of 75 miles of running across all my sessions. In this post, I&rsquo;ll reflect on my journey so far and what I&rsquo;ve learned.</p>
<h2 id="the-beginning">The Beginning</h2>
<p>Over the summer, I lived and worked in Austin, Texas. For the prior months of the year, I&rsquo;ve lived a fairly sedimentary lifestyle. I enjoy these summers where I am forced to walk more places and <a href="/blog/exploring-via-public-transit/">take public transit</a> since I don&rsquo;t have access to a vehicle.</p>
<p>Two of my coworkers were really into running and one of them even regularly ran a few miles outside in the <a href="/blog/embrace-the-heat/">95+ degree Fahrenheit weather</a>. This didn&rsquo;t immediately convince me to start myself, but it definitely planted the seed. Towards the end of the summer, I asked myself &ldquo;What&rsquo;s stopping me from running?&rdquo; and took this as an opportunity to prove something to myself.</p>
<blockquote>
<p>What do I need to get started?</p></blockquote>
<p>At the beginning, I didn&rsquo;t end up buying anything new. In Austin, I wore some sneakers from <a href="https://www.keenfootwear.com">Keen footwear</a> which I accidentally soaked a few times in sudden downpours. I also wear a Fitbit watch that has heart rate monitoring built in.</p>
<p>When I returned to New York, I ended up buying a <a href="https://www.brooksrunning.com/en_us">Brooks running shoe</a>. Shoes designed for running are usually lighter and have shock absorption. This makes for a more pleasant workout.</p>
<blockquote>
<p>Did you use a training plan?</p></blockquote>
<p>Initially, I used the <a href="https://www.nhs.uk/better-health/get-active/get-running-with-couch-to-5k/couch-to-5k-running-plan/">couch to 5k running plan</a> developed by the UK&rsquo;s National Health Service (NHS). After completing that plan, I charted out my own path, with most of my sessions consisting of 30 minutes of running surrounded by 5 minutes of walking.</p>
<p>When I returned to New York, I saw that my city was hosting a <a href="https://troyturkeytrot.com/">turkey trot</a> on Thanksgiving. That was a perfect amount of time to follow the program and see how things turn out!</p>
<h2 id="training-arc">Training Arc</h2>
<p>With a race in mind, I had to make sure to not skimp on my training. Initially, I had an every other day schedule. Though as I adjusted back to my new schedule, this has become a little chaotic.</p>
<h3 id="running-inside-vs-outside">Running Inside vs Outside</h3>
<p>The transition between summer and fall in New York state is lovely. It makes for a great time to run outside.  However, as the months rolled by it started getting colder and colder. Now that we&rsquo;re in the midst of winter, I&rsquo;ve mostly abandoned running outside.</p>
<p>Some people swear by running outdoors versus using a treadmill. I&rsquo;m fine with either, but there are definitely trade-offs.</p>
<p>With outdoor running there is much more visual stimuli. Instead of staring at a wall, the scenery changes as you move around. This is a huge pro when it comes to staving off boredom; which I find very important for a successful workout. The uneven ground also prevents repetitive strain injuries.</p>
<p>On the other hand, it&rsquo;s very easy to keep pace with a treadmill. Another benefit is that you don&rsquo;t have to plan out a route ahead of time. Though, I find it imperative to have some sort of distraction ready for when I do my treadmill runs. Initially I turned to watching YouTube videos, but I noticed that my posture would then suffer. I find podcasts to be a nice compromise.</p>
<h3 id="run-slowly">Run Slowly</h3>
<p>I&rsquo;m far from an expert in this area, but it&rsquo;s generally recommended to keep your <a href="https://en.wikipedia.org/wiki/Long_slow_distance">heart rate low</a> when you&rsquo;re running. This helps improve your cardiovascular fitness over the long run. An informal test is to see if you can carry a conversation while running.</p>
<p>Formally, it&rsquo;s recommended to stay in &ldquo;Zone 2&rdquo; while running which is about 60-70% of your max heart rate.  We can use the Bruce protocol to find out our max heart rate, or for a much simpler approximation, we can use the Tanaka, Monahan, &amp; Seals (2001) formula.
$$
hr_{max} = 208 - (0.7 * age)
$$
That means for a 30 year old their zone 2 heart rate lies between 112 and 130.</p>
<p>In Zone 2, the body can still supply energy using fat reserves and clear up the lactate before it builds up. It&rsquo;s easy when running to go past this heart rate. The best way I&rsquo;ve found to keeping it in zone 2 is to run <em>really slow</em>. Often much slower than you think you should be running. When I was first starting out, I wasn&rsquo;t going much faster than walking pace.</p>
<h3 id="running-with-others">Running with others</h3>
<p>A friend recently got me to attend the local run club in the city. At first, I was nervous about joining since I thought it would only be full of people who ran their whole life. It turns out that it had people at all different points in the running journey. I joined once a week until it started regularly staying below freezing. I hope to attend again next year.</p>
<p>Either way, running can be a great excuse to get together with others!</p>
<h2 id="the-turkey-trot">The Turkey Trot</h2>
<p>We then arrive at the big day! I showed up around thirty minutes before the event started. To give an idea of the scale, the Troy turkey trot had over 4000 finishers for the 5k.</p>
<p>At the beginning of the race, everyone lined up by flags marking different paces. I timed myself at a nearby track, and my last 5k was 35 minutes. Feeling optimistic, I lined up at the 11 minute flag.</p>
<p><img src="/files/images/blog/ttslpaceflags.jpg" alt="High-up view of my starting position"></p>
<p>Bam! The sound of the gun then went off signaling the start of the race &ndash; except there was no action. Since I was so far back from the start line, it took over 5 minutes before me and the others around me got to shuffling.</p>
<p>Luckily, when it comes to timing there&rsquo;s the gun time and the net time. The net time is the time it takes once you cross the &ldquo;start line&rdquo; to hit the &ldquo;finish line&rdquo;.</p>
<p>With the majority of my running sessions featuring me, myself, and I, running with so many other people gave a huge energy boost. I let the energy get to me, and I didn&rsquo;t end up running a consistent pace at all. I likely ran a little too fast in the beginning, resulting in short walking breaks towards the end. Also I underestimated the energy it would take to weave around other people.</p>
<p>I hit the finish line at <a href="https://www.zippy-reg.com/results/live/athlete/index.php?eid=228&amp;bib=1508">34 minutes and 14 seconds</a>! It&rsquo;s pretty exciting to hit a new personal best during a race.</p>
<p>Focusing on the race, I didn&rsquo;t end up taking too many photos. Instead here&rsquo;s a blurry snapshot from the <a href="https://www.youtube.com/watch?v=fLRtp4DtcB8">finish cam</a> and a selfie of me with my participation medal.</p>
<p><img src="/files/images/blog/ttfc2025.png" alt="Screenshot of me passing the finish line with many others"></p>
<p><img src="/files/images/blog/ttselfie2025.jpg" alt="Selfie of me with the participation medal"></p>
<h2 id="onto-the-future">Onto the future!</h2>
<p>With that milestone completed, now it&rsquo;s time to think about what&rsquo;s next. Do I train for a 10k? A half-marathon? Nothing is set in stone yet, and I&rsquo;m keeping my eye out for future events.</p>
<p>I mentioned that one of my challenges with running is starving off boredom. While the race does inject a new form of energy, I still currently can&rsquo;t see running for multiple hours!</p>
<p>Beyond running itself, I find it fun to look at my logs. For example, we can take the times from the Troy turkey trot, and plot my performance on the histogram of all participant net times.</p>
<p><img src="/files/images/blog/tthistnettime2025.png" alt="Histogram of net times for all the participants"></p>
<p>Additionally, from my overall training log, I can see that I run on average 2.5 miles during my 30 minute runs.  I&rsquo;m excited to see how these metrics improve over time.</p>
]]></content:encoded>
  
</item>
  
  <item>
  <title>Disabling Nat Source Port Randomization on OPNsense for Gaming</title>
  <link>https://brandonrozek.com/blog/disabling-nat-source-port-randomization-opnsense/</link>
  <pubDate>Tue, 23 Dec 2025 11:16:02 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/disabling-nat-source-port-randomization-opnsense/</guid>
  <description><![CDATA[<blockquote>
<p>Let&rsquo;s play Mario Party tonight!</p></blockquote>
<p>After many years of friendship, I&rsquo;ve learned that playing online multiplayer games is almost never as simple as it seems. This night was no different.  In this post, I&rsquo;ll go over what I learned setting up my Nintendo Switch for online play. Luckily, this same concept applies to the PlayStation 5 as well<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p>
<p>But first, I&rsquo;ll take a detour into how peer-to-peer (P2P) gaming typically works. So, feel free to <a href="#the-solution">skip</a> down to the solution.</p>]]></description>
  <content:encoded><![CDATA[<blockquote>
<p>Let&rsquo;s play Mario Party tonight!</p></blockquote>
<p>After many years of friendship, I&rsquo;ve learned that playing online multiplayer games is almost never as simple as it seems. This night was no different.  In this post, I&rsquo;ll go over what I learned setting up my Nintendo Switch for online play. Luckily, this same concept applies to the PlayStation 5 as well<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p>
<p>But first, I&rsquo;ll take a detour into how peer-to-peer (P2P) gaming typically works. So, feel free to <a href="#the-solution">skip</a> down to the solution.</p>
<h2 id="peer-to-peer-gaming">Peer-to-Peer Gaming</h2>
<p>Mario Party is a board game where you move characters around in hopes of collecting the most stars. You play as a character from the Mario franchise, and between each turn on the board is a minigame. These minigames provide the illusion that this is a skill-based game. But trust me, you can win by just tapping A.</p>
<p>Sony and Nintendo both aren&rsquo;t forthcoming with information about how their games and systems work. Therefore, I&rsquo;ll be making a number of assumptions here.</p>
<p>Nintendo owns Mario Party, and from there we can presume that they use the standard Nintendo libraries when building the game. The homebrew community over the years reverse-engineered many of these libraries and due to this we can look at the protocols they use. The Nintendo networking library, called <a href="https://github.com/kinnay/NintendoClients/wiki/Pia-Overview">Pia</a>, uses a service called NEX to match players in games. This match-making protocol includes a <a href="https://en.wikipedia.org/wiki/UDP_hole_punching">network-address-translation (nat) traversal protocol</a>.</p>
<p>For sake of simplicity, I won&rsquo;t describe the protocol in full-depth. What&rsquo;s important to know is that when both players message the server, the server knows each player&rsquo;s IP address and the source port that they used to communicate. The server will then share the other console&rsquo;s information to facilitate that direct peer-to-peer connection</p>
<p>Unfortunately, I was not able to find either official or unofficial documentation on how P2P gaming works on the PlayStation 5. From browsing around, I have the impression that this is less standardized and developers are either relying on external SDKs or developing their own libraries.</p>
<h2 id="the-problem">The Problem</h2>
<p>Opnsense, in all their great wisdom, wants to protect me from <a href="https://en.wikipedia.org/wiki/TCP_sequence_prediction_attack">TCP hijacking</a> and spoofing attacks. Therefore, by default during nat my source port is randomized.</p>
<p>Now the game consoles will not tell us directly that this is the issue. Instead it will provide a &ldquo;score&rdquo; which is supposed to establish a rough sense of how easy it will be to establish a P2P connection.</p>
<p>Nintendo Switch: Settings -&gt; Internet -&gt; Test Connection</p>
<p>PS5: Settings -&gt; Network -&gt; Test Internet Connection</p>
<p>Before applying any changes, on the switch I had a &ldquo;D&rdquo; score on on the PS5 it was &ldquo;Type 3&rdquo;. I wish I can tell you what these scores mean, but the documentation is lacking to say the least&hellip;</p>
<p>However, I was finally able to determine that nat source port randomization was the problem after stumbling upon this <a href="https://www.reddit.com/r/OPNsenseFirewall/comments/g3sx2l/tip_opnsense_and_nintendo_switch_nat_rules/">Reddit thread</a> &ndash; which now takes us to the solution:</p>
<h2 id="the-solution">The Solution</h2>
<p>We need to tell our router that our gaming devices are special, and therefore does not need the additional security measure of randomizing the source port during nat. That way when the game is establishing a P2P connection, the ports aren&rsquo;t randomized and the connection can proceed smoothly.</p>
<p>On Opnsense, we can change this setting by navigating to Firewall -&gt; NAT -&gt; Outbound.</p>
<p>Now I do want this security measure to be applied to the rest of my devices. Therefore, we&rsquo;ll be setting the mode to &ldquo;Hybrid outbound NAT rule generation&rdquo;  which allows us to specify the custom rules.</p>
<p>From there, we can add a manual rule for each game console with the following:</p>
<ul>
<li>Interface: WAN</li>
<li>TCP/IP Version: IPv4</li>
<li>Protocol: UDP</li>
<li>Source Address: Single host or network
<ul>
<li>Insert Switch/PS5 address</li>
<li>Replace netmask with 32</li>
</ul>
</li>
<li>Source Port: any</li>
<li>Destination Address: any</li>
<li>Destination Port: Any</li>
<li>Static Port: <strong>Checked</strong></li>
</ul>
<p>The last option is what tells Opnsense to not randomize the source port.</p>
<p>From there, we can save, apply our settings, and rerun our connection tests. With this change, my Switch reports a NAT type of B and PS5 reports Type 2. Good enough for online gaming :)</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Sorry Xbox folks, I do not own one so I cannot tell you if this technique works for that platform as well.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
  <category>Networking</category>
  
</item>
  
  <item>
  <title>Fedora CoreOS: First Impressions</title>
  <link>https://brandonrozek.com/blog/fedora-coreos-first-impressions/</link>
  <pubDate>Fri, 28 Nov 2025 10:47:11 -0500</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/fedora-coreos-first-impressions/</guid>
  <description><![CDATA[<p>I have a VPS whose contract ends in December. Instead of renewing, I decided to switch providers of that VPS to OVHCloud. The latter&rsquo;s commitment to <a href="https://corporate.ovhcloud.com/en/sustainability/environment/">sustainability</a> through renewables and component reuse is super cool. Now, I could&rsquo;ve kept the migration simple and keep the configuration the same. I don&rsquo;t write about it much here, but I have <a href="https://www.redhat.com/en/ansible-collaborative">Ansible</a> playbooks for all my servers. However, <a href="https://www.zdnet.com/article/what-is-immutable-linux-heres-why-youd-run-an-immutable-linux-distro/">immutable Linux distributions</a> have been receiving a lot of attention over the past few years and tragically I knew little about them.</p>]]></description>
  <content:encoded><![CDATA[<p>I have a VPS whose contract ends in December. Instead of renewing, I decided to switch providers of that VPS to OVHCloud. The latter&rsquo;s commitment to <a href="https://corporate.ovhcloud.com/en/sustainability/environment/">sustainability</a> through renewables and component reuse is super cool. Now, I could&rsquo;ve kept the migration simple and keep the configuration the same. I don&rsquo;t write about it much here, but I have <a href="https://www.redhat.com/en/ansible-collaborative">Ansible</a> playbooks for all my servers. However, <a href="https://www.zdnet.com/article/what-is-immutable-linux-heres-why-youd-run-an-immutable-linux-distro/">immutable Linux distributions</a> have been receiving a lot of attention over the past few years and tragically I knew little about them.</p>
<p><strong>What is an immutable Linux distribution?</strong> It is a Linux distribution with a read-only core. This prevents accidental modifications which overtime lead to an unstable system.</p>
<p>There are <a href="https://nixos.org/">many</a> <a href="https://ubuntu.com/core">different</a> <a href="https://microos.opensuse.org/">options</a> for these immutable distributions, but as you can see from the title of this post, I went with <a href="https://www.fedoraproject.org/coreos/">Fedora CoreOS</a>. The reason is simple. All my other servers run Fedora Server, so hopefully the changes I need to make to my playbooks are minimal.</p>
<p>At the time of writing, Fedore CoreOS uses <a href="https://ostreedev.github.io/ostree/introduction/">OSTree</a> to perform upgrades over the entire filesystem. These upgrades are <em>atomic</em> which suggests that we are not updating individual packages but the entire Linux distribution as a whole. Apart from the read-only core, there are two writable directories <code>/etc</code> and <code>/var</code>. In fact, your home directory lives in <code>/var/home</code>.</p>
<h2 id="initial-installation">Initial Installation</h2>
<p>OVHCloud does not have a way to upload an ISO and boot directly from there. Instead, we&rsquo;ll have to use their rescue mode feature. Luckily, Timothée wrote up a <a href="https://tim.siosm.fr/blog/2025/09/14/fedora-coreos-ovhcloud-vps/">great guide</a> on how to get started. If this doesn&rsquo;t exactly match your situation, the <a href="https://docs.fedoraproject.org/en-US/fedora-coreos/getting-started/">official documentation</a> has over 20 different provisioning guides.</p>
<p>When I was following the documentation, one of the parts I got tripped up on was how much configuration to put in my Butane file. As noted above, I already have Ansible setup to copy over various files I need. Then I saw that <em>ignition runs only once during the first boot of the system</em>. Hence, if we wanted to customize our storage partitions or lay out networking, then this is a good place to do this. Otherwise, if we want to setup <code>systemd</code> services and the like, we can always add those via Ansible later.</p>
<p>In other words, if you&rsquo;re fine with the defaults and there&rsquo;s a DHCP server running on your network, then the  <em>base Butane config outlined in the documentation is sufficient</em>.</p>
<p>From the official documentation (write this to a file such as <code>config.bu</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">variant</span>: <span style="color:#ae81ff">fcos</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">version</span>: <span style="color:#ae81ff">1.6.0</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">passwd</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">users</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">core</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">ssh_authorized_keys</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">ssh-rsa AAAA...</span>
</span></span></code></pre></div><p>Where you replace the <code>ssh-rsa</code> line with your own SSH public key file.</p>
<p>Then, in order to get the ignition file, we can run the following command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>podman run --interactive --rm quay.io/coreos/butane:release --pretty --strict &lt; config.bu &gt; config.ign
</span></span></code></pre></div><h2 id="running-software">Running Software</h2>
<p>For the most part, using OSTree to install additional packages is <a href="https://docs.fedoraproject.org/en-US/fedora-coreos/faq/#_how_do_i_run_custom_applications_on_fedora_coreos">highly discouraged</a>. Instead, it&rsquo;s suggested to install and run things through <a href="https://docs.fedoraproject.org/en-US/fedora-coreos/running-containers/">containers</a>. The official documentation shows how to set up containers via the Butane configuration above, however, I kept my file as simple as shown above.  Instead since <code>/etc/containers/systemd</code> is writable, I wrote <a href="https://brandonrozek.com/blog/migrating-docker-compose-podman-quadlets/">Podman Quadlet files</a> directly (<a href="https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html">Official Quadlet Documentation</a>).</p>
<p>For example, here is my Wireguard Quadlet file</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Unit]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Description</span><span style="color:#f92672">=</span><span style="color:#e6db74">WireGuard VPN Container</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">After</span><span style="color:#f92672">=</span><span style="color:#e6db74">network-online.target</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Wants</span><span style="color:#f92672">=</span><span style="color:#e6db74">network-online.target</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Container]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Image</span><span style="color:#f92672">=</span><span style="color:#e6db74">docker.io/linuxserver/wireguard:latest</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ContainerName</span><span style="color:#f92672">=</span><span style="color:#e6db74">wireguard</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Give the container the ability to add network interfaces</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">AddCapability</span><span style="color:#f92672">=</span><span style="color:#e6db74">NET_ADMIN</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Have the wireguard network accessible on the host</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Network</span><span style="color:#f92672">=</span><span style="color:#e6db74">host</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Mount the WireGuard configuration directory</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Volume</span><span style="color:#f92672">=</span><span style="color:#e6db74">/etc/wireguard:/config/wg_confs:Z</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">Restart</span><span style="color:#f92672">=</span><span style="color:#e6db74">always</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">TimeoutStartSec</span><span style="color:#f92672">=</span><span style="color:#e6db74">900</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[Install]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">WantedBy</span><span style="color:#f92672">=</span><span style="color:#e6db74">multi-user.target default.target</span>
</span></span></code></pre></div><p>The following are my Ansible tasks which copy that file over to the machine.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>- <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Create /etc/wireguard directory</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">become</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ansible.builtin.file</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#ae81ff">/etc/wireguard</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">state</span>: <span style="color:#ae81ff">directory</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">mode</span>: <span style="color:#e6db74">&#39;0700&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">owner</span>: <span style="color:#ae81ff">root</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">group</span>: <span style="color:#ae81ff">root</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Copy Quadlet container file</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">become</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ansible.builtin.copy</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">src</span>: <span style="color:#ae81ff">etc/containers/systemd/wireguard.container</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">dest</span>: <span style="color:#ae81ff">/etc/containers/systemd/wireguard.container</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">mode</span>: <span style="color:#e6db74">&#39;0644&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">owner</span>: <span style="color:#ae81ff">root</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">group</span>: <span style="color:#ae81ff">root</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">register</span>: <span style="color:#ae81ff">wireguardcontainer</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Reload systemd daemon to pick up Quadlet</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">become</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ansible.builtin.systemd</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">daemon_reload</span>: <span style="color:#66d9ef">yes</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">when</span>: <span style="color:#ae81ff">wireguardcontainer.changed</span>
</span></span></code></pre></div><p>Now not everything deserves a spot in <code>/etc/containers/systemd</code>. Take <code>fastfetch</code> for example.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-txt" data-lang="txt"><span style="display:flex;"><span>             .&#39;,;::::;,&#39;.                 core@toolbx
</span></span><span style="display:flex;"><span>         .&#39;;:cccccccccccc:;,.             ------------
</span></span><span style="display:flex;"><span>      .;cccccccccccccccccccccc;.          OS: Fedora Linux 43 (Toolbx Container Image) x86_64
</span></span><span style="display:flex;"><span>    .:cccccccccccccccccccccccccc:.        Host: OpenStack Nova (19.3.2)
</span></span><span style="display:flex;"><span>  .;ccccccccccccc;.:dddl:.;ccccccc;.      Kernel: Linux 6.17.1-300.fc43.x86_64
</span></span><span style="display:flex;"><span> .:ccccccccccccc;OWMKOOXMWd;ccccccc:.     Uptime: 22 hours, 16 mins
</span></span><span style="display:flex;"><span>.:ccccccccccccc;KMMc;cc;xMMc;ccccccc:.    Packages: 366 (rpm)
</span></span><span style="display:flex;"><span>,cccccccccccccc;MMM.;cc;;WW:;cccccccc,    Shell: bash 5.3.0
</span></span><span style="display:flex;"><span>:cccccccccccccc;MMM.;cccccccccccccccc:    Terminal: conmon
</span></span><span style="display:flex;"><span>:ccccccc;oxOOOo;MMM000k.;cccccccccccc:    CPU: 6 x Intel Core (Haswell, no TSX) (6) @ 2.99 GHz
</span></span><span style="display:flex;"><span>cccccc;0MMKxdd:;MMMkddc.;cccccccccccc;    GPU: Cirrus Logic GD 5446
</span></span><span style="display:flex;"><span>ccccc;XMO&#39;;cccc;MMM.;cccccccccccccccc&#39;    Memory: 920.56 MiB / 11.39 GiB (8%)
</span></span><span style="display:flex;"><span>ccccc;MMo;ccccc;MMW.;ccccccccccccccc;     Swap: Disabled
</span></span><span style="display:flex;"><span>ccccc;0MNc.ccc.xMMd;ccccccccccccccc;      Disk (/): 11.53 GiB / 99.44 GiB (12%) - overlay
</span></span><span style="display:flex;"><span>cccccc;dNMWXXXWM0:;cccccccccccccc:,       Disk (/run/host/boot): 277.41 MiB / 349.87 MiB (79%) - ext4 [Read-only]
</span></span><span style="display:flex;"><span>cccccccc;.:odl:.;cccccccccccccc:,.        Disk (/run/host/etc): 11.53 GiB / 99.44 GiB (12%) - xfs
</span></span><span style="display:flex;"><span>ccccccccccccccccccccccccccccc:&#39;.          
</span></span><span style="display:flex;"><span>:ccccccccccccccccccccccc:;,..             
</span></span><span style="display:flex;"><span> &#39;:cccccccccccccccc::;,.
</span></span></code></pre></div><p>All it does it displays system information. Now this is a very important task when showing off your system on Reddit, but it&rsquo;s more of a <em>system administration</em> tool than a software service that your server provides. For these types of tools, we can use <code>toolbox</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>toolbox create <span style="color:#75715e"># Run this once</span>
</span></span><span style="display:flex;"><span>toolbox enter
</span></span></code></pre></div><p>This will give us a new prompt:</p>
<pre tabindex="0"><code>⬢ [core@toolbx ~]$
</code></pre><p>From here, we can use <code>dnf</code> and treat it similarly as a mutable Fedora server machine.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo dnf install fastfetch
</span></span></code></pre></div><p>Now you&rsquo;ll notice that by default it mounts your home directory but the <code>/etc</code> and <code>/var</code> directories are those of the container. We can find all the files in the host system by accessing <code>/run/host</code>.</p>
<p>I use this when trying to view my Nginx logs:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>goaccess /run/host/var/log/nginx/access.log
</span></span></code></pre></div><h2 id="system-security">System Security</h2>
<p>A huge benefit to running all your services via Quadlets is that Podman is able to automatically set the SELinux contexts and configure your firewall rules for you. This means that we don&rsquo;t have to manually change the SELinux context of every file with <code>chcon</code>. If you&rsquo;re lazy, this means hopefully we don&rsquo;t have to disable SELinux!</p>
<p><strong>SELinux:</strong> Notice in my Wireguard Quadlet file I had the following</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">Volume</span><span style="color:#f92672">=</span><span style="color:#e6db74">/etc/wireguard:/config/wg_confs:Z</span>
</span></span></code></pre></div><p>The part after <code>wg_confs:</code> is an optional comma-separated list of options. Here are some that I found to be particularly relevant:</p>
<table>
  <thead>
      <tr>
          <th>Option</th>
          <th>Description</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Z</code></td>
          <td>Label the content with a private unshared SELinux label.</td>
      </tr>
      <tr>
          <td><code>z</code></td>
          <td>Label the content with a shared SELinux content label so that two or more containers can access it.</td>
      </tr>
      <tr>
          <td><code>U</code></td>
          <td>Recursively change the owner and group of the source volume based on the UID and GID of the container</td>
      </tr>
      <tr>
          <td><code>ro</code></td>
          <td>The container can only read, not write to the volume</td>
      </tr>
  </tbody>
</table>
<p><strong>NFTables:</strong> My other Fedora server systems use <code>firewalld</code> as the primary way I interface with the firewall. Instead of using a CLI tool, the idea is that we edit <code>/etc/sysconfig/nftables.conf</code> with the rules we want. I&rsquo;m still getting used writing my firewall config this way, but I do like how it&rsquo;s all easily viewable in one place.</p>
<p>Here&rsquo;s a version of what I have:</p>
<pre tabindex="0"><code class="language-nftables" data-lang="nftables">#!/usr/sbin/nft -f

# Define the main table
table inet filter {
    
    # Data structure we&#39;ll use to keep track of rate-limiting
    set ssh_ratelimit {
        type ipv4_addr
        size 65536
        flags dynamic,timeout
        timeout 30s
    }

    chain input {
        type filter hook input priority filter; policy drop;

        # Allow loopback
        iif lo accept

        # Allow established/related connections
        ct state established,related accept

        # Allow ICMP (ping, etc)
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept

        # SSH with rate limiting
        tcp dport 22 ct state new limit rate over 12/minute burst 6 packets drop
        tcp dport 22 accept

        # Allow HTTP and HTTPS
        tcp dport { 80, 443 } accept

        # Drop everything else
        drop
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
        
        # Allow established/related connections
        ct state established,related accept
    }

    chain output {
    	# Allow outgoing connections
        type filter hook output priority filter; policy accept;
    }
}
</code></pre><p>I won&rsquo;t go into detail how NFTables works here. Notice though how we don&rsquo;t specify anything about the Podman network. As I said before, Podman will automatically handle that for us.</p>
<p>However, what Podman won&rsquo;t automatically handle is if we try to change our NFTables configuration without a reboot. This is because reloading NFTables will <em>wipe the existing configuration</em>. As such, we need to manually invoke Podman to regenerate the rules.</p>
<p>Luckily, we can override the NFTables systemd service so that it happens automatically. Create the file <code>/etc/systemd/system/nftables.service.d/override.conf</code> with the following:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[Service]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecStartPost</span><span style="color:#f92672">=</span><span style="color:#e6db74">podman network reload --all</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ExecReload</span><span style="color:#f92672">=</span><span style="color:#e6db74">podman network reload --all</span>
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>That wraps up the bits and pieces I had to learn while I was setting my machine up. From there, I&rsquo;ve been running my CoreOS machine for a month and have not yet had any issues. It&rsquo;s too early for me to make any grand claims, but it <em>feels</em> incredibly reliable.</p>
<p>By default, updates are automatic. Since these are atomic, it means that the machine reboots regularly to apply these updates. This encourages us to setup everything to survive reboots and not require any manual intervention. Also, boot times are quick with it clocking under 12 seconds for my VPS.</p>
<p>Running everything in containers provides isolation between these services and the host. This is useful of course for security, but also allows everything to update on their own cadence and not conflict.</p>
<p>Overall, using an immutable distribution is different than traditional Linux server management. However hopefully with this setup, it incentivizes us to create less fragile systems. A community of maintainers will keep the stable read-only core in a great state, and if things go wrong, we can copy our container volumes to another machine.</p>
<p>So if you haven&rsquo;t already, I recommend giving Fedora CoreOS a shot. Next, I have to take a look at how it&rsquo;s like using an immutable distribution on a desktop.</p>
]]></content:encoded>
  <category>Fedora CoreOS</category>
  <category>Podman</category>
  <category>SELinux</category>
  <category>nftables</category>
  
</item>
  
  <item>
  <title>Flattening Cases to Avoid Nesting in Lean 4</title>
  <link>https://brandonrozek.com/blog/flattening-cases-avoid-nesting-lean-4/</link>
  <pubDate>Sun, 05 Oct 2025 19:38:20 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/flattening-cases-avoid-nesting-lean-4/</guid>
  <description><![CDATA[<p>Nested cases in proofs increase cognitive load for the reader since they have to process not only the case recently stated but also all the case splits prior. That&rsquo;s why if I can, I prefer to flatten out my cases so that we can see in one step all the variables we&rsquo;re segmenting.</p>
<p>I came across this recently in Lean when working on Lattice proofs over integers with $\infty$ and $-\infty$  In Lean, we can define this &ldquo;extended integer&rdquo; (<code>EInt</code>) by using <code>WithTop</code> and <code>WithBot</code></p>]]></description>
  <content:encoded><![CDATA[<p>Nested cases in proofs increase cognitive load for the reader since they have to process not only the case recently stated but also all the case splits prior. That&rsquo;s why if I can, I prefer to flatten out my cases so that we can see in one step all the variables we&rsquo;re segmenting.</p>
<p>I came across this recently in Lean when working on Lattice proofs over integers with $\infty$ and $-\infty$  In Lean, we can define this &ldquo;extended integer&rdquo; (<code>EInt</code>) by using <code>WithTop</code> and <code>WithBot</code></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">import Mathlib.Order.Interval.Basic

-- An Integer with a top (∞) and bottom (-∞) element
def EInt : Type := WithBot (WithTop Int)
deriving LinearOrder

@[simp] def EInt.ninf : EInt := (⊥ : WithBot (WithTop Int))
@[simp] def EInt.inf : EInt := (WithBot.some ⊤ : WithBot (WithTop Int))

notation &#34;-∞&#34; =&gt; EInt.ninf
notation &#34;∞&#34; =&gt; EInt.inf

-- Helper instances so I can later write numbers and have them casted
instance: IntCast EInt where
  intCast n := WithBot.some (WithTop.some n)

instance: OfNat EInt n where
  ofNat := some (some (Int.ofNat n))
</code></pre><p>Unfortunately, using <code>WithBot</code> and <code>WithTop</code> is just weird. Look at how we would define functions and write proofs using them.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">def EIntFun : EInt → ℤ
  | none =&gt; 0
  | some ⊤ =&gt; 0
  | some (some _) =&gt; 0

lemma EIntFunIsZero (e: EInt) : EIntFun e = 0 := by
  cases e
  case none =&gt;
    rfl
  case some e =&gt;
    cases e
    case top =&gt;
      rfl
    case coe e =&gt;
      rfl
</code></pre><p>What&rsquo;s more intuitive is to break it up based on whether it&rsquo;s $-\infty$, $\infty$ or some integer $z$. Luckily, we&rsquo;re able to define our own way of splitting up cases in Lean.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">def EInt.casesOn.{u} {motive : EInt -&gt; Sort u} (a : EInt)
  (ninf : motive -∞)
  (int : ∀ n : Int, motive ↑n)
  (pinf : motive ∞) :
motive a := by
  cases a
  case none =&gt;
    exact ninf
  case some v =&gt;
    cases v
    case top =&gt;
      exact pinf
    case coe n =&gt;
      exact int n
</code></pre><p>When we&rsquo;re doing a proof by cases, we&rsquo;re trying to prove some <code>motive a</code>. What the above definition says, is that if we&rsquo;re given some EInt <code>a</code> and three proofs regarding the motive of each of the cases, then performing the cases successfully proves the motive.</p>
<p>Notice how the proof now no longer has any nested cases:</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EIntFunIsZero2 (e: EInt) : EIntFun e = 0 := by
  cases e using EInt.casesOn
  case ninf =&gt;
    rfl
  case int z =&gt;
    rfl
  case pinf =&gt;
    rfl
</code></pre><p>In fact, we can even use this trick to simplify our function</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">def EIntFun2 (e: EInt) : ℤ := by
  cases e using EInt.casesOn with
  | ninf =&gt;
    exact 0
  | int z =&gt;
    exact 0
  | pinf =&gt;
    exact 0
</code></pre><h2 id="a-more-complicated-example">A more complicated example</h2>
<p>Personally I find utility in defining how we want our cases to go prior to the proof itself. For example, let&rsquo;s break up the domain further by considering the sign of our integers.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">def EInt.casesOnSigns.{u} {motive : EInt -&gt; Sort u} (a : EInt)
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) :
motive a := by
  cases a
  case none =&gt;
    exact ninf
  case some v =&gt;
    cases v
    case top =&gt;
      exact pinf
    case coe n =&gt;
      by_cases n &lt; 0
      case pos H =&gt;
        exact nint n H
      case neg H =&gt;
        by_cases n = 0
        case pos H2 =&gt;
          exact zero n H2
        case neg H2 =&gt;
          have H3 : n &gt; 0 := by
            have HH : n ≥ 0 := Int.not_lt.mp H
            have HH2 : n ≠ 0 := H2
            have HH3 : 0 ≠ n := Ne.symm HH2
            exact lt_of_le_of_ne HH HH3
          exact pint n H3
</code></pre><p>The more complicated we make our cases, the more Lean will struggle to establish definitional equality. I personally find it useful to create helper lemmas for each of the cases to later help establish our motive.</p>
<p><strong>Negative Infinity Case</strong></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">@[simp]
lemma EInt.casesOnSigns.is_ninf.{u} {motive : EInt -&gt; Sort u}
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) : EInt.casesOnSigns (-∞) ninf nint zero pint pinf = ninf := rfl
</code></pre><p>The above says that if we perform a cases on $-\infty$ then the result will be equivalent to the <code>ninf</code> case.</p>
<p><strong>Negative Integer Case</strong></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EInt.casesOnSigns.is_nint.{u} {motive : EInt -&gt; Sort u}
  (z : Int)
  (Hz : z &lt; 0)
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) :
  EInt.casesOnSigns (↑z) ninf nint zero pint pinf = nint z Hz := by
  unfold casesOnSigns
  show (casesOn (↑z) ninf (fun n =&gt; if h : n &lt; 0 then nint n h
                                    else if h : n = 0 then zero n h
                                    else pint n (by omega : n &gt; 0)) pinf) = nint z Hz
  change (if h : z &lt; 0 then nint z h
          else if h : z = 0 then zero z h
          else pint z (by omega : z &gt; 0)) = nint z Hz
  rw [dif_pos Hz]
</code></pre><p>With our usage of inequalities, we have to help guide Lean through this proof. We first unfold the <code>casesOnSigns</code> definition to get the goal shown in the <code>show</code> tactic. From there, we already know that our EInt is the integer <code>z</code>, so we can simplify the cases to our nested if-then-else statement.  After that, since we have the hypothesis that our integer <code>z</code> is negative, we can directly get the <code>nint z h</code> case from the consequent of the outer ite.</p>
<p><strong>Zero Case</strong></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">@[simp]
lemma EInt.casesOnSigns.is_zero.{u}  {motive : EInt -&gt; Sort u}
  (z : Int)
  (Hn : z = 0)
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) : (EInt.casesOnSigns (z) ninf nint zero pint pinf)  = zero z Hn := by
    subst z
    rfl
</code></pre><p>Once we substitute zero in, Lean can automatically establish definitional equality.</p>
<p><strong>Positive Case</strong></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EInt.casesOnSigns.is_pint.{u}  {motive : EInt -&gt; Sort u}
  (z : Int)
  (Hz : z &gt; 0)
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) : (EInt.casesOnSigns (z) ninf nint zero pint pinf)  = pint z Hz := by
  unfold casesOnSigns
  show (casesOn (↑z) ninf (fun n =&gt; if h : n &lt; 0 then nint n h
                                    else if h : n = 0 then zero n h
                                    else pint n (by omega : n &gt; 0)) pinf) = pint z Hz
  change (if h : z &lt; 0 then nint z h
          else if h : z = 0 then zero z h
          else pint z (Hz : z &gt; 0)) = pint z Hz
  have HH: ¬(z &lt; 0) := not_lt_of_gt Hz
  rw [dif_neg HH]
  have HH2: ¬(z = 0) := Int.ne_of_gt Hz
  rw [dif_neg HH2]
</code></pre><p>Similar to the negative case, but we need to do slightly more work to get to the last alternative within the nested if-then-else statement.</p>
<p><strong>Infinity Case</strong></p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">@[simp]
lemma EInt.casesOnSigns.is_pinf.{u} {motive : EInt -&gt; Sort u}
  (ninf : motive -∞)
  (nint : ∀ n : Int, n &lt; 0 → motive ↑n)
  (zero: ∀ n : Int, n = 0 → motive ↑n)
  (pint : ∀ n : Int, n &gt; 0 → motive ↑n)
  (pinf : motive ∞) : EInt.casesOnSigns (∞) ninf nint zero pint pinf = pinf := rfl
</code></pre><hr>
<p>Like before, now that we have our new definition <code>EInt.casesOnSigns</code>, we can create our function which determines whether an EInt is positive cleanly.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">def EInt.isPos (e: EInt) : Bool := by
  cases e using EInt.casesOnSigns with
  | ninf =&gt; exact false
  | nint _ =&gt; exact false
  | zero =&gt; exact false
  | pint _ =&gt; exact true
  | pinf =&gt; exact true
</code></pre><p>This is much better than if we worked with the original <code>WithTop</code> and <code>WithBot</code> version! Now let&rsquo;s prove that our EInt is positive iff it is greater than zero. To do this we&rsquo;ll need one helper lemma.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EInt.coe_lt_coe {a b : Int}:  ((↑a : EInt) &lt; (↑b: EInt)) ↔ a &lt; b := by
  have H1 : ((↑a : EInt) &lt; (↑b: EInt)) → a &lt; b := by
    intro (H: (↑a : EInt) &lt; (↑b: EInt))
    apply WithTop.coe_lt_coe.mp
    exact WithBot.coe_lt_coe.mp H
  have H2 : a &lt; b → ((↑a : EInt) &lt; (↑b: EInt)) := by
    clear H1
    intro (H : a &lt; b)
    apply WithBot.coe_lt_coe.mpr
    exact WithTop.coe_lt_coe.mpr H
  exact Iff.intro H1 H2
</code></pre><p>The above lemma states that if the integer $a$ is less than the integer $b$, then the EInt version of $a$ is less than the EInt version of $b$ and vice versa.  Now for the main proof</p>
<p><strong>Soundness</strong></p>
<p>If our EInt <code>e</code> is positive, then <code>e</code> is greater than zero.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EInt.isPos_sound (e: EInt) : e.isPos = true → e &gt; 0 := by
  intro H
  cases e using EInt.casesOnSigns
  case ninf =&gt;
    contradiction
  case nint n Hn =&gt;
    unfold EInt.isPos at H
    rw [EInt.casesOnSigns.is_nint n Hn] at H
    contradiction
  case zero n Hz =&gt;
    unfold EInt.isPos at H
    rw [EInt.casesOnSigns.is_zero n Hz] at H
    contradiction
  case pint n Hp =&gt;
    apply EInt.coe_lt_coe.mpr Hp
  case pinf =&gt;
    exact Batteries.compareOfLessAndEq_eq_lt.mp rfl
</code></pre><p><strong>Completeness</strong></p>
<p>If our EInt <code>e</code> is greater than zero, then <code>e</code> is positive.</p>
<pre tabindex="0"><code class="language-lean4" data-lang="lean4">lemma EInt.isPos_complete (e: EInt) : e &gt; 0 → e.isPos = true := by
  intro H
  cases e using EInt.casesOnSigns
  case ninf =&gt;
    contradiction
  case nint n Hn =&gt;
    have H2 : n &gt; 0 := EInt.coe_lt_coe.mp H
    have H3 : ¬(n &gt; 0) := not_lt_of_gt Hn
    contradiction
  case zero n Hz =&gt;
    have H2 : n &gt; 0 := EInt.coe_lt_coe.mp H
    have H3 : ¬(n &gt; 0) := Eq.not_gt Hz
    contradiction
  case pint n Hp =&gt;
    unfold EInt.isPos
    rw [EInt.casesOnSigns.is_pint n Hp _ _ _ _ _ ]
  case pinf =&gt;
    unfold EInt.isPos
    rw [EInt.casesOnSigns.is_pinf]
</code></pre>]]></content:encoded>
  <category>Lean</category>
  <category>Proof assistant</category>
  <category>Formal Proof</category>
  
</item>
  
  <item>
  <title></title>
  <link></link>
  <pubDate>Sun, 07 Sep 2025 17:50:44 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid></guid>
  <description><![CDATA[]]></description>
  <content:encoded><![CDATA[]]></content:encoded>
  
</item>
  
  <item>
  <title>Cursed Knowledge: Javascript Arrays Are Objects</title>
  <link>https://brandonrozek.com/blog/cursed-knowledge-javascript-arrays-are-objects/</link>
  <pubDate>Mon, 01 Sep 2025 09:47:01 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/cursed-knowledge-javascript-arrays-are-objects/</guid>
  <description><![CDATA[<p>My friend Ethan recently wrote a blog post on <a href="https://emar10.dev/posts/cursed-commands-part-1/">cursed commands</a>. Chris shared with me that Immich has a page on their site called <a href="https://immich.app/cursed-knowledge/">cursed knowledge</a>, and it looks like this has started a trend. I&rsquo;ve seen my fair share of the dark arts in programming, so I&rsquo;ll hop on and share what I know about JavaScript arrays.</p>
<p>JavaScript arrays are <a href="https://262.ecma-international.org/#sec-array-exotic-objects"><em>exotic objects</em></a> according to the ECMAScript specification. Therefore, they may lead to unintuitive behavior if we think of these arrays as C-like.</p>]]></description>
  <content:encoded><![CDATA[<p>My friend Ethan recently wrote a blog post on <a href="https://emar10.dev/posts/cursed-commands-part-1/">cursed commands</a>. Chris shared with me that Immich has a page on their site called <a href="https://immich.app/cursed-knowledge/">cursed knowledge</a>, and it looks like this has started a trend. I&rsquo;ve seen my fair share of the dark arts in programming, so I&rsquo;ll hop on and share what I know about JavaScript arrays.</p>
<p>JavaScript arrays are <a href="https://262.ecma-international.org/#sec-array-exotic-objects"><em>exotic objects</em></a> according to the ECMAScript specification. Therefore, they may lead to unintuitive behavior if we think of these arrays as C-like.</p>
<p>Let&rsquo;s play around.</p>
<h3 id="concept-1-javascript-arrays-are-not-continguous">Concept 1: JavaScript arrays are not continguous</h3>
<p>First, consider the following array:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">x</span> <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>];
</span></span></code></pre></div><p>As one might expect, <code>x.length</code> is equal to <code>3</code>. To tell whether or not an index is in an array, we can use the <code>in</code> operator.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ae81ff">3</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">x</span> <span style="color:#75715e">// Evaluates to false
</span></span></span></code></pre></div><p>If we try to access the 3rd index, the result will evaluate to <code>undefined</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">3</span>] <span style="color:#75715e">// Evaluates to undefined
</span></span></span></code></pre></div><p>Now let&rsquo;s assign an element to the 4th index. Keep in mind that we&rsquo;re skipping over the 3rd one.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">4</span>] <span style="color:#f92672">=</span> <span style="color:#ae81ff">4</span>;
</span></span></code></pre></div><p>Now when we check our <code>length</code> property, it&rsquo;ll say that our array is now of size <code>5</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span>.<span style="color:#a6e22e">length</span> <span style="color:#75715e">// Evaluates to 5
</span></span></span></code></pre></div><p>However, the 3rd index still does not exist</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ae81ff">3</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">x</span> <span style="color:#75715e">// Evaluates to false
</span></span></span></code></pre></div><h3 id="concept-2-explicit-vs-implicit-undefined">Concept 2: Explicit vs Implicit <code>undefined</code></h3>
<p>Recall that <code>x[3]</code> evaluates to <code>undefined</code>. What happens when we set the value explicitly?</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">3</span>] <span style="color:#f92672">=</span> <span style="color:#66d9ef">undefined</span>;
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">3</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">x</span> <span style="color:#75715e">// Evaluates to true
</span></span></span></code></pre></div><p>So there is a difference on whether we have explicitly set an index to <code>undefined</code>! This distinction is not always used. For example, our trusty for-of loop does not care.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span> <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>];
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">4</span>] <span style="color:#f92672">=</span> <span style="color:#ae81ff">4</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#a6e22e">a</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">x</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">a</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Will print out</p>
<pre tabindex="0"><code>0
1
2
undefined
4
</code></pre><h3 id="concept-3-indices-are-actually-strings">Concept 3: Indices are actually strings</h3>
<p>Given that we have a length property and that we&rsquo;ve been indexing with numeric keys, it must mean that arrays have numeric indices. Right?</p>
<pre tabindex="0"><code>&#34;0&#34; in [&#34;a&#34;, &#34;b&#34;] // Evaluates to true
</code></pre><p>Okay, it looks like there&rsquo;s some conversion magic that&rsquo;s happening behind the scenes here. The ECMAScript specification says that an array index must be strictly less than $2^{32}$. So what happens if it is not?</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">x</span> <span style="color:#f92672">=</span> [<span style="color:#ae81ff">0</span>];
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">4294967296</span>] <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">x</span> <span style="color:#75715e">// Evaluates to [ 0, &#39;4294967296&#39;: true ]
</span></span></span></code></pre></div><p>It looks like it no longer gets treated as an array item, but instead treats it as an arbitrary key-value pair. Why stop there, this must mean that we can store any sort of arbitrary data in our array.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">x</span>.<span style="color:#a6e22e">name</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Brandon&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">x</span> <span style="color:#75715e">// Evaluates to [ 0, &#39;4294967296&#39;: true, name: &#39;Brandon&#39; ]
</span></span></span></code></pre></div><h3 id="viewing-arrays-as-objects">Viewing arrays as objects</h3>
<p>Now everything starts to make more sense when we think of these arrays as objects.</p>
<pre tabindex="0"><code class="language-javscript" data-lang="javscript">let x = [0, 1, 2];
x[4] = 4;
</code></pre><p>Internally, this corresponds to the object:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;0&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;1&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;2&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;4&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">4</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>From this object, we can see that the keys are strings and that the 3rd key is not in the object. Now let&rsquo;s see what happens when we explicitly set <code>x[5] = undefined</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;0&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;1&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;2&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;4&#34;</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">4</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;5&#34;</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">undefined</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The 5th key is now in our object and it&rsquo;s set to an undefined value. We also get an undefined value when we try to retrieve a value of a key that is not in our object.</p>
<p>The length of our array is the highest &ldquo;numeric&rdquo; key within our object (subject to the size limit). When we iterate over our array using <code>for-of</code>, we&rsquo;re iterating from <code>&quot;0&quot;</code> to our length.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#a6e22e">a</span> <span style="color:#66d9ef">of</span> <span style="color:#a6e22e">x</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">a</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Is the same as:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">0</span>]);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">1</span>]);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">2</span>]);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">3</span>]);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">4</span>]);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">x</span>[<span style="color:#ae81ff">5</span>]);
</span></span></code></pre></div><p>That&rsquo;s an exotic object for you.</p>
]]></content:encoded>
  <category>JavaScript</category>
  
</item>
  
  <item>
  <title></title>
  <link></link>
  <pubDate>Sat, 26 Jul 2025 15:56:50 +0000</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid></guid>
  <description><![CDATA[<p>The polls are now open over at USPS to determine which forever stamp to bring back.</p><p><a href="https://www.stampsforever.com/vote" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://www.</span><span class="">stampsforever.com/vote</span><span class="invisible"></span></a></p><p>The stamps that they make are always cool and it&#39;s great to see recognition for some of the earlier designs.</p>]]></description>
  <content:encoded><![CDATA[<p>The polls are now open over at USPS to determine which forever stamp to bring back.</p><p><a href="https://www.stampsforever.com/vote" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://www.</span><span class="">stampsforever.com/vote</span><span class="invisible"></span></a></p><p>The stamps that they make are always cool and it&#39;s great to see recognition for some of the earlier designs.</p>]]></content:encoded>
  
</item>
  
  <item>
  <title>Deterministically Iterating over a set within Dafny functions</title>
  <link>https://brandonrozek.com/blog/deterministic-set-iteration-dafny/</link>
  <pubDate>Sun, 06 Jul 2025 12:27:01 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/deterministic-set-iteration-dafny/</guid>
  <description><![CDATA[<p>Say we have a set that we want to iterate over within a pure Dafny function. For sake of example, we will look at a set of strings. In Dafny,  <code>var x :| condition</code> denotes &ldquo;let us define variable x such that [condition]&rdquo;. Therefore, a first attempt at writing our function might be:</p>
<pre tabindex="0"><code>function iterate_helper(collection: set&lt;string&gt;, acc: seq&lt;string&gt;): seq&lt;string&gt;
{
    if collection == {} then acc
    else
        var x :| x in collection;
        var newAcc := acc + [x];
        var newCollection := collection - {x};
        iterate_helper(newCollection, newAcc)
}
</code></pre><p>The issue is that Dafny will complain with the following error message:</p>]]></description>
  <content:encoded><![CDATA[<p>Say we have a set that we want to iterate over within a pure Dafny function. For sake of example, we will look at a set of strings. In Dafny,  <code>var x :| condition</code> denotes &ldquo;let us define variable x such that [condition]&rdquo;. Therefore, a first attempt at writing our function might be:</p>
<pre tabindex="0"><code>function iterate_helper(collection: set&lt;string&gt;, acc: seq&lt;string&gt;): seq&lt;string&gt;
{
    if collection == {} then acc
    else
        var x :| x in collection;
        var newAcc := acc + [x];
        var newCollection := collection - {x};
        iterate_helper(newCollection, newAcc)
}
</code></pre><p>The issue is that Dafny will complain with the following error message:</p>
<blockquote>
<p>to be compilable, the value of a let-such-that expression must be uniquely determined</p></blockquote>
<p>Dafny functions must be deterministic. This means that no matter how many times we call a function with some specified input, we will always get the same output. Therefore, as the error message suggests, we need to write a condition that <em>uniquely</em> determines <code>x</code>. One way to achieve this is to specify an order as described in <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/krml252.pdf">Rustan&rsquo;s paper</a>:</p>
<pre tabindex="0"><code>function iterate_helper(collection: set&lt;string&gt;, acc: seq&lt;string&gt;): seq&lt;string&gt;
{
    if collection == {} then acc
    else
        var x :| x in collection &amp;&amp; forall y | y in collection :: x &lt;= y;
        var newAcc := acc + [x];
        var newCollection := collection - {x};
        iterate_helper(newCollection, newAcc)
}
</code></pre><p>Unfortunately, this code will return two errors:</p>
<blockquote>
<p>cannot establish the existence of LHS values that satisfy the such-that predicate</p></blockquote>
<blockquote>
<p>to be compilable, the value of a let-such-that expression must be uniquely determined</p></blockquote>
<p>However, it&rsquo;s totally possible to define an ordering over strings. We&rsquo;ll just need to do some work to convince the verifier of this.</p>
<h3 id="comparing-two-strings">Comparing two strings</h3>
<p>Since strings in Dafny are a sequence of characters, it turns out that the <code>&lt;=</code> relation checks whether the left side is a prefix of the right side. Therefore, there is no such thing as a <em>unique</em> minimum element, since there are incomparable elements like &ldquo;a&rdquo; and &ldquo;b&rdquo;.</p>
<p>As such, our first step is to create a less than or equal to (<code>&lt;=</code>) relation that induces a total order.  Consider the following function that determines the order based on the left-most character.</p>
<pre tabindex="0"><code class="language-function" data-lang="function">function string_le(s1: string, s2: string): bool
    decreases |s1| + |s2|
{
    if |s1| == 0 &amp;&amp; |s2| &gt; 0 then
        true
    else if |s1| &gt; 0 &amp;&amp; |s2| == 0 then
        false
    else if |s1| == 0 &amp;&amp; |s2| == 0 then
        true
    else
        assert(|s1| &gt; 0);
        assert(|s2| &gt; 0);
        var c1 := s1[0];
        var c2 := s2[0];
        if c1 &lt; c2 then
            true
        else if c1 &gt; c2 then
            false
        else
            string_le(s1[1..], s2[1..])
}
</code></pre><h3 id="properties-of-our-comparison-function">Properties of our comparison function</h3>
<p>From here we need to prove that our relation induces a total order. For this, we need to show that it is reflexive, total, anti-symmetric, and transitive.</p>
<p>Reflexivity: All strings are less than or equal to themselves</p>
<pre tabindex="0"><code>lemma string_le_reflexive()
    ensures forall s :: string_le(s ,s)
{
    forall s ensures string_le(s, s)
    {
        string_le_reflexive_helper(s);
    }
}

lemma string_le_reflexive_helper(s1: string)
    ensures string_le(s1, s1)
{}
</code></pre><p>Totality: Given two strings, one is less than or equal to the other.</p>
<pre tabindex="0"><code>lemma string_le_totality()
    ensures forall s1, s2 :: string_le(s1, s2) || string_le(s2, s1)
{
    forall s1, s2 ensures string_le(s1, s2) || string_le(s2, s1)
    {
        string_le_totality_helper(s1, s2);
    }
}

lemma string_le_totality_helper(s1: string, s2: string)
    ensures string_le(s1, s2) || string_le(s2, s1)
{}
</code></pre><p>Antisymmetric: If one string is less than or equal to another string and that other string is also less than or equal to the original string then both strings are equivalent.</p>
<pre tabindex="0"><code>lemma string_le_antisymmetric()
    ensures forall s1, s2 :: string_le(s1, s2) &amp;&amp; string_le(s2, s1) ==&gt; s1 == s2
{
    forall s1, s2 | string_le(s1, s2) &amp;&amp; string_le(s2, s1)
    ensures s1 == s2
    {
        string_le_antisymmetric_helper(s1, s2);
    }
}

lemma string_le_antisymmetric_helper(s1: string, s2: string)
    requires string_le(s1, s2)
    requires string_le(s2, s1)
    ensures s1 == s2
{}
</code></pre><p>Transitive: If one string is less than or equal to another string, and that other string is less than or equal to some third string, then the first string is less than or equal to that third string.</p>
<pre tabindex="0"><code>lemma string_le_transitive()
    ensures forall s1, s2, s3 :: string_le(s1, s2) &amp;&amp; string_le(s2, s3) ==&gt; string_le(s1, s3)
{
    forall s1, s2, s3 | string_le(s1, s2) &amp;&amp; string_le(s2, s3)
    ensures string_le(s1, s3)
    {
        string_le_transitive_helper(s1, s2, s3);
    }
}

lemma string_le_transitive_helper(s1: string, s2: string, s3: string)
    requires string_le(s1, s2)
    requires string_le(s2, s3)
    ensures string_le(s1, s3)
{}
</code></pre><p>Then, we conviniently package all the properties together:</p>
<pre tabindex="0"><code>lemma string_le_properties()
    ensures forall s :: string_le(s, s)
    ensures forall s1, s2 :: string_le(s1, s2) &amp;&amp; string_le(s2, s1) ==&gt; s1 == s2
    ensures forall s1, s2, s3 :: string_le(s1, s2) &amp;&amp; string_le(s2, s3) ==&gt; string_le(s1, s3)
    ensures forall s1, s2 :: string_le(s1, s2) || string_le(s2, s1)
{
    string_le_reflexive();
    string_le_antisymmetric();
    string_le_transitive();
    string_le_totality();
}
</code></pre><h3 id="string-sets-have-a-minimum">String sets have a minimum</h3>
<p>With the total ordering of strings, we can prove that a smallest element exists within a non-empty set <code>s</code>. First, let us invoke our comparison properties lemma, so the verifier has access to those properties:</p>
<pre tabindex="0"><code>string_le_properties();
</code></pre><p>Since our set is non-empty, we can grab an arbitrary element from <code>s</code>.</p>
<pre tabindex="0"><code>var x :| x in s;
</code></pre><p>For this proof, we&rsquo;ll approach it inductively. First, let&rsquo;s consider when <code>s == {x}</code>.</p>
<ol>
<li>By construction, every element in <code>s</code> is equal to <code>x</code>.</li>
<li>Then by reflexivity, <code>x</code> is smaller than every element in <code>s</code>.</li>
</ol>
<pre tabindex="0"><code>assert forall y :: y in s ==&gt; y == x;
assert forall y :: y in s ==&gt; string_le(x, y);
</code></pre><p>Now let&rsquo;s consider the inductive case. The set in this case has more elements than just <code>x</code>. Let&rsquo;s consider <code>s'</code> the subset of <code>s</code> without the element <code>x</code>.</p>
<pre tabindex="0"><code>var s&#39; := s - {x};
assert s&#39; != {};
</code></pre><p>Since <code>s'</code> is non-empty, we can by induction say that  <code>s'</code> has a smallest element.</p>
<pre tabindex="0"><code>string_smallest_exists(s&#39;);
var x&#39; :| x&#39; in s&#39; &amp;&amp; forall y :: y in s&#39; ==&gt; string_le(x&#39;, y);
</code></pre><p>As <code>s'</code> is the subset of <code>s</code> without <code>x</code>, we can assert that <code>x'</code> and <code>x</code> are not the same:</p>
<pre tabindex="0"><code>assert x&#39; != x;
</code></pre><p>From here, we compare both <code>x</code> and <code>x'</code> (which we&rsquo;re able to do since <code>&lt;=</code> is total)</p>
<p>Case 1: <code>x &lt;= x'</code>: By transitivity, <code>x</code> will be less than all the elements of <code>s'</code>. Since <code>s'</code> is  <code>s</code>  without <code>x</code>, we can safely say that <code>x</code> is less than every element in <code>s</code>.</p>
<pre tabindex="0"><code>assert forall y :: y in s&#39; ==&gt; string_le(x, y);
assert forall y :: y in s ==&gt; string_le(x, y);
</code></pre><p>Case 2: <code>!(x &lt;= x')</code>. From totality, we have that <code>x' &lt;= x</code>. Since we know from the inductive hypothesis that <code>x'</code> is the minimum of <code>s'</code> and <code>s</code> is <code>s'</code> with the element <code>x</code>, we can conclude that <code>x'</code> is the smallest element of <code>s</code>.</p>
<pre tabindex="0"><code>assert !string_le(x, x&#39;);
assert string_le(x&#39;, x);
assert forall y :: y in s ==&gt; string_le(x&#39;, y);
</code></pre><p>With that, we&rsquo;ve proven that a smallest string exists! Here&rsquo;s the lemma in its entirety:</p>
<pre tabindex="0"><code>lemma string_smallest_exists(s: set&lt;string&gt;)
    requires s != {}
    decreases s
    ensures exists x :: x in s &amp;&amp; forall y :: y in s ==&gt; string_le(x, y)
{
    string_le_properties();

    var x :| x in s;

    // Base Case
    if s == {x} {
        assert forall y :: y in s ==&gt; y == x;
        assert forall y :: y in s ==&gt; string_le(x, y);
	
    // Inductive Case
    } else {
        var s&#39; := s - {x};
        assert s&#39; != {};
        string_smallest_exists(s&#39;);
        var x&#39; :| x&#39; in s&#39; &amp;&amp; forall y :: y in s&#39; ==&gt; string_le(x&#39;, y);
        assert x != x&#39;;

        if string_le(x, x&#39;) {
            assert forall y :: y in s&#39; ==&gt; string_le(x, y);
            assert forall y :: y in s ==&gt; string_le(x, y);
        } else {
            // x&#39; is smaller than x
            assert !string_le(x, x&#39;);
            assert string_le(x&#39;, x);
            assert forall y :: y in s ==&gt; string_le(x&#39;, y);
        }
    }
}
</code></pre><h3 id="select-the-smallest-element-from-a-set">Select the smallest element from a set</h3>
<p>Now that we have determined that a minimum exists in a set, we can use these lemmas to establish that we can uniquely determine the element that we select based on the ordering.</p>
<pre tabindex="0"><code>function select_string_from_set(collection: set&lt;string&gt;): string
    requires collection != {}
{
    string_le_properties();
    string_smallest_exists(collection);
    var value :| value in collection &amp;&amp; forall y | y in collection :: string_le(value, y);
    value
}
</code></pre><h3 id="conclusion">Conclusion</h3>
<p>Revisiting our iteration example, we can use our new <code>select_string_from_set</code> function to iterate over a set of strings in a pure deterministic function. More specifically, we&rsquo;ll visit all the elements in the collection in the order defined by our relation.</p>
<pre tabindex="0"><code>function iterate_helper(collection: set&lt;string&gt;, acc: seq&lt;string&gt;): seq&lt;string&gt;
    decreases collection
{
    if collection == {} then acc
    else
        var x := select_string_from_set(collection);
        var newAcc := acc + [x];
        var newCollection := collection - {x};
        iterate_helper(newCollection, newAcc)
}
</code></pre><p>From here you can generalize beyond a set of strings, as long as you&rsquo;re able to prove the properties of a total order. The full code for our string sets example is below:</p>
<pre tabindex="0"><code>function string_le(s1: string, s2: string): bool
    decreases |s1| + |s2|
{
    if |s1| == 0 &amp;&amp; |s2| &gt; 0 then
        true
    else if |s1| &gt; 0 &amp;&amp; |s2| == 0 then
        false
    else if |s1| == 0 &amp;&amp; |s2| == 0 then
        true
    else
        assert(|s1| &gt; 0);
        assert(|s2| &gt; 0);
        var c1 := s1[0];
        var c2 := s2[0];
        if c1 &lt; c2 then
            true
        else if c1 &gt; c2 then
            false
        else
            string_le(s1[1..], s2[1..])
}

lemma string_le_reflexive()
    ensures forall s :: string_le(s ,s)
{
    forall s ensures string_le(s, s)
    {
        string_le_reflexive_helper(s);
    }
}

lemma string_le_reflexive_helper(s1: string)
    ensures string_le(s1, s1)
{}

lemma string_le_totality()
    ensures forall s1, s2 :: string_le(s1, s2) || string_le(s2, s1)
{
    forall s1, s2 ensures string_le(s1, s2) || string_le(s2, s1)
    {
        string_le_totality_helper(s1, s2);
    }
}

lemma string_le_totality_helper(s1: string, s2: string)
    ensures string_le(s1, s2) || string_le(s2, s1)
{}

lemma string_le_antisymmetric()
    ensures forall s1, s2 :: string_le(s1, s2) &amp;&amp; string_le(s2, s1) ==&gt; s1 == s2
{
    forall s1, s2 | string_le(s1, s2) &amp;&amp; string_le(s2, s1)
    ensures s1 == s2
    {
        string_le_antisymmetric_helper(s1, s2);
    }
}

lemma string_le_antisymmetric_helper(s1: string, s2: string)
    requires string_le(s1, s2)
    requires string_le(s2, s1)
    ensures s1 == s2
{}


lemma string_le_transitive()
    ensures forall s1, s2, s3 :: string_le(s1, s2) &amp;&amp; string_le(s2, s3) ==&gt; string_le(s1, s3)
{
    forall s1, s2, s3 | string_le(s1, s2) &amp;&amp; string_le(s2, s3)
    ensures string_le(s1, s3)
    {
        string_le_transitive_helper(s1, s2, s3);
    }
}

lemma string_le_transitive_helper(s1: string, s2: string, s3: string)
    requires string_le(s1, s2)
    requires string_le(s2, s3)
    ensures string_le(s1, s3)
{}

lemma string_le_properties()
    ensures forall s :: string_le(s, s)
    ensures forall s1, s2 :: string_le(s1, s2) &amp;&amp; string_le(s2, s1) ==&gt; s1 == s2
    ensures forall s1, s2, s3 :: string_le(s1, s2) &amp;&amp; string_le(s2, s3) ==&gt; string_le(s1, s3)
    ensures forall s1, s2 :: string_le(s1, s2) || string_le(s2, s1)
{
    string_le_reflexive();
    string_le_antisymmetric();
    string_le_transitive();
    string_le_totality();
}


lemma string_smallest_exists(s: set&lt;string&gt;)
    requires s != {}
    decreases s
    ensures exists x :: x in s &amp;&amp; forall y :: y in s ==&gt; string_le(x, y)
{
    string_le_properties();

    var x :| x in s;

    if s == {x} {
        assert forall y :: y in s ==&gt; y == x;
        assert forall y :: y in s ==&gt; string_le(x, y);
    } else {
        // For sets with more than one element, we use induction-like reasoning
        var s&#39; := s - {x};

        assert s&#39; != {};
        string_smallest_exists(s&#39;);

        var x&#39; :| x&#39; in s&#39; &amp;&amp; forall y :: y in s&#39; ==&gt; string_le(x&#39;, y);
        assert x != x&#39;;

        if string_le(x, x&#39;) {
            assert forall y :: y in s&#39; ==&gt; string_le(x, y);
            assert forall y :: y in s ==&gt; string_le(x, y);
        } else {
            // x&#39; is smaller than x
            assert !string_le(x, x&#39;);
            assert string_le(x&#39;, x);
            assert forall y :: y in s ==&gt; string_le(x&#39;, y);
        }
    }
}

function select_string_from_set(collection: set&lt;string&gt;): string
    requires collection != {}
{
    string_le_properties();
    string_smallest_exists(collection);
    var value :| value in collection &amp;&amp; forall y | y in collection :: string_le(value, y);
    value
}

function iterate_helper(collection: set&lt;string&gt;, acc: seq&lt;string&gt;): seq&lt;string&gt;
    decreases collection
{
    if collection == {} then acc
    else
        var x := select_string_from_set(collection);
        var newAcc := acc + [x];
        var newCollection := collection - {x};
        iterate_helper(newCollection, newAcc)
}
</code></pre>]]></content:encoded>
  <category>Dafny</category>
  <category>Formal methods</category>
  <category>Deterministic algorithm</category>
  <category>Total order</category>
  
</item>
  
  <item>
  <title>Dealing with Web Scrapers</title>
  <link>https://brandonrozek.com/blog/anti-scraper-techniques/</link>
  <pubDate>Wed, 02 Jul 2025 09:10:23 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/anti-scraper-techniques/</guid>
  <description><![CDATA[<p>Nowadays it seems like every tech company is eager to scrape the web. Unfortunately, it seems like
<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> the majority of traffic that comes to this small site are scrapers. While my static website is able to handle the load, the same cannot be said about everyone.</p>
<p>Overall, the techinques I&rsquo;ve seen website owners use aim to make scraping more difficult. Though it&rsquo;s a balance. The harder we make it for bots to access a website, the more we turn away regular humans as well. Here&rsquo;s a short and non-exhaustive list of techinques:</p>]]></description>
  <content:encoded><![CDATA[<p>Nowadays it seems like every tech company is eager to scrape the web. Unfortunately, it seems like
<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> the majority of traffic that comes to this small site are scrapers. While my static website is able to handle the load, the same cannot be said about everyone.</p>
<p>Overall, the techinques I&rsquo;ve seen website owners use aim to make scraping more difficult. Though it&rsquo;s a balance. The harder we make it for bots to access a website, the more we turn away regular humans as well. Here&rsquo;s a short and non-exhaustive list of techinques:</p>
<ol>
<li>User Agent Filtering</li>
<li>CAPTCHA solving</li>
<li>Rate Limiting</li>
<li>Proof of work</li>
<li>Identification</li>
<li>Paywall</li>
</ol>
<h3 id="user-agent-filtering">User Agent Filtering</h3>
<p>When a person/bot requests a page from a website, the HTTP header of the request has a field called <code>User-Agent</code>.  This is to denote the type of client that the requester is using. For example, when I visited a website just now, I sent the user agent <code>Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0</code>.</p>
<p>Filtering based on this string is the easiest technique to employ and also has a low chance of impacting regular humans visiting the website. <a href="https://www.rfc-editor.org/rfc/rfc9309.html">RFC 9309 Robots Exclusion Protocol</a>, more commonly known as <code>robots.txt</code>, is the most common way of implementing this technique.</p>
<p>How it works is that you create a file named <code>robots.txt</code> at the root directory of your website and write a set of rules that different robots <em>should</em> follow. Here&rsquo;s an example from <a href="https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt">Google&rsquo;s search documentation</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-txt" data-lang="txt"><span style="display:flex;"><span>User-agent: Googlebot
</span></span><span style="display:flex;"><span>Disallow: /nogooglebot/
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>User-agent: *
</span></span><span style="display:flex;"><span>Allow: /
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Sitemap: https://www.example.com/sitemap.xml
</span></span></code></pre></div><p>The <code>*</code> here is the Klenne star which means that it can match any string. Before the bot requests a page, the idea is that they first request this <code>robots.txt</code> file, find the rules that match their user agent, and follow it&rsquo;s instructions.</p>
<p>As you might imagine, not everyone writes scrapers that follow these rules. This depends on how well-written the bot was and how considerate the developer is. An alternative to this approach is to block the request at the web server. For example, here&rsquo;s how you would do that using <code>nginx</code></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#e6db74">(</span>$http_user_agent = <span style="color:#e6db74">&#34;Googlebot&#34;)</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">return</span> <span style="color:#ae81ff">403</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This returns an empty response with the HTTP code <code>403 Forbidden</code>.</p>
<p>The downside to this approach is that it&rsquo;s easy to pretend that you have a different user agent. For example on my machine, the user agent set by <code>curl</code> is <code>curl/8.9.1</code>. However, I can use the same user agent as my browser by adding a flag:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl --user-agent <span style="color:#e6db74">&#34;Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0&#34;</span> https://brandonrozek.com
</span></span></code></pre></div><h3 id="captcha-solving">CAPTCHA Solving</h3>
<p>The Completely Automated Public Turing test to tell Computers and Humans Apart (CAPTCHA) is a challenge-response approach to dealing with bots. The idea is that the webserver would present some sort of challenge that is supposedly hard for computers to solve but easy for humans. The human responds to the challenge and then is granted access to the website.</p>
<p>In the paper &ldquo;Recent advances of Captcha security analysis: a short literature review&rdquo; by Nghia Trong Dinh and Vinh Truong Hoang, they show that for the majority of CAPTCHA systems, bots are successful at solving them over 50% of the time. Specifically, the best bots are able to solve Google&rsquo;s image-based CAPTCHAs with 70.78% accuracy.</p>
<p>Unfortunately<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> the success rate of bots are bound to improve over time. Additionally, CAPTCHA systems are annoying to humans. For example, when I use a VPN, I don&rsquo;t bother with Google search since I don&rsquo;t want to select pictures of stairs, fire hydrants, or crosswalks 10 times before being granted a search query.</p>
<h3 id="rate-limiting">Rate Limiting</h3>
<p>Computers are inherently faster than us. In the paper &ldquo;How many words do we read per minute? A review and meta-analysis of reading rate&rdquo; by Marc Brysbaert, he writes that the average human adult reads 238 words per minute of non-fiction silently. Thus, it would take a human on average almost 11 hours to read all my prior blog posts (assuming they don&rsquo;t get tired or distracted). Meanwhile a bot can scrape this site in under a minute.</p>
<p>From this insight, one technique is to limit the number of requests that an IP address can make at any given time. This is formally known as <em>rate limiting</em>.</p>
<p>It sounds simple in concept but can be difficult to implement without impacting user experience. How many requests is a human reasonably allowed to make in a minute? Human traffic is typically bursty, where a page load can request many different files (CSS, JS, media) in a short period of time.  How quick can I expect someone to reasonably click around my website? If this isn&rsquo;t dialed in properly, then rate limiting can cause frustration with your visitors.</p>
<p>I&rsquo;m also unsure how successful this is against the LLM web scrapers. Nowadays there are bot farms where they each have their own IP address. It&rsquo;s difficult to determine whether a request is from a human visitor or part of a larger bot collection network.</p>
<h3 id="proof-of-work">Proof of work</h3>
<p>We talked about how CAPTCHAs are difficult for computers but easy for humans. Proof of work is difficult for both computers and humans. This helps reduce the number of scrapers by making it <em>costly</em> to request resources from the website. By making the web browser solve some proof of work challenge (usually involving hash functions), the request consumes additional CPU cycles and takes additional time.</p>
<p>Similar to rate limiting, how <em>difficult</em> you make the problem has a direct impact on user experience. The more difficult, the longer it&rsquo;ll take for the web browser to solve it. This will deter more bots, but after a few seconds will also deter human visitors. <a href="https://web.archive.org/web/20250121155519/https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/page-load-time-statistics/">According to a study performed by Google and SOASTA Research in 2017</a>, if a user has to wait 3 seconds instead of 1 second, then the probability that they <em>bounce</em> (leave the page) increases by 32%.</p>
<p>Recently, open-source projects <a href="https://anubis.techaro.lol/">Anubis</a> and <a href="https://git.gammaspectra.live/git/go-away">go-away</a> gained popularity for making it easy to implement this technique. It&rsquo;s popular for git forges like <a href="https://git.sr.ht/">sourcehut&rsquo;s</a> as scraping those incurs a lot of CPU cycles in traversing git repositories.</p>
<h3 id="identification">Identification</h3>
<p>Another tactic is to ask the requester to provide some information that a human would likely have but a bot less so. Examples include email addresses, phone number, government ID, etc. Of course, a bot can supply false information, but as with the other techinques this adds an additional barrier. Watch out for the <a href="https://gregoryhammond.ca/blog/never-to-connect-phone-numbers-a-project/">fake phone numbers</a>.</p>
<h3 id="paywall">Paywall</h3>
<p>Lastly, you can require users to pay to see the contents of your website. This is popular with news organizations where they ask you to pay for a subscription in order to see content. This ties in well with the previous tactic, because if the user pays for a subscription, then you likely have a lot of identifying information about that user.</p>
<p>Another interesting idea that I haven&rsquo;t seen widely implemented is requiring some amount of money per interaction. This can be in the form of the <a href="https://webmonetization.org/">Web Monetization API</a> or via cryptocurrency like Bitcoin on the <a href="https://lightning.network/">Lightning network</a>. <a href="https://stacker.news/">Stacker news</a> is an example of a Reddit-like platform where users need to pay a small fee in order to upvote a post. The idea is to make it cheap for a human to do on a small scale (like 1 cent per up-vote), but expensive for a bot to do at scale.</p>
<h3 id="conclusion">Conclusion</h3>
<p>We&rsquo;re in a special time period where everyone is fighting to become the top AI company. Long term, I feel that the scraper activity will die down. Similar to how there weren&rsquo;t as many web search scrapers out there.</p>
<p>In the meantime, these are multiple techniques to consider if your website is suffering under heavy load. As for myself, I don&rsquo;t currently implement any of these as my website is mostly static and I haven&rsquo;t noticed my servers being overloaded.</p>
<p>However if you do, I urge you to exercise some caution. For the most part, we share on the web for information to flow freely, and if we&rsquo;re not careful we may drive people away.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>At least I don&rsquo;t think a human using Chrome would try to visit my homepage every minute.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Or fortunately, if we want to get closer to AGI&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
  <category>web scraping</category>
  <category>CAPTCHA</category>
  <category>rate limiting</category>
  <category>robots.txt</category>
  <category>proof of work</category>
  
</item>
  
  <item>
  <title>Exploring via Public Transit</title>
  <link>https://brandonrozek.com/blog/exploring-via-public-transit/</link>
  <pubDate>Sun, 15 Jun 2025 21:38:02 -0400</pubDate>
  <author>brozek@brandonrozek.com (Brandon Rozek)</author>
  <guid>https://brandonrozek.com/blog/exploring-via-public-transit/</guid>
  <description><![CDATA[<p>Last weekend, on my way back north from visiting downtown, I took a break near the Crestview lightrail station. There, I had a wonderful meal at the <a href="https://kurasushi.com/locations/austin-tx-airport-blvd/">Kura revolving sushi bar</a>. Afterwards, I walked around the <a href="https://usa.kinokuniya.com/stores-kinokuniya-austin">Kinokuniya Bookstore</a>. Honestly, this spot is not something I would&rsquo;ve naturally discovered on my own.</p>
<p><img src="/files/images/blog/202506071440.png" alt="Image of Sushi Conveyor Belt"></p>
<p>Using public transportation is a great way to explore the neighborhoods around you. Busses often don&rsquo;t take highways, and instead will take you through areas that you would&rsquo;ve otherwise skipped. You can find many great restaurants to eat in the Bay Area right next to the Mountain View Caltrain station. A few years ago when Clare and I visited Portland, Maine, we got to explore the thousand islands by a <a href="https://www.cascobaylines.com/maine-boat-tours/specialty-cruises/mailboat/">mail boat</a>.</p>]]></description>
  <content:encoded><![CDATA[<p>Last weekend, on my way back north from visiting downtown, I took a break near the Crestview lightrail station. There, I had a wonderful meal at the <a href="https://kurasushi.com/locations/austin-tx-airport-blvd/">Kura revolving sushi bar</a>. Afterwards, I walked around the <a href="https://usa.kinokuniya.com/stores-kinokuniya-austin">Kinokuniya Bookstore</a>. Honestly, this spot is not something I would&rsquo;ve naturally discovered on my own.</p>
<p><img src="/files/images/blog/202506071440.png" alt="Image of Sushi Conveyor Belt"></p>
<p>Using public transportation is a great way to explore the neighborhoods around you. Busses often don&rsquo;t take highways, and instead will take you through areas that you would&rsquo;ve otherwise skipped. You can find many great restaurants to eat in the Bay Area right next to the Mountain View Caltrain station. A few years ago when Clare and I visited Portland, Maine, we got to explore the thousand islands by a <a href="https://www.cascobaylines.com/maine-boat-tours/specialty-cruises/mailboat/">mail boat</a>.</p>
<p>Even if you have a car, it&rsquo;s worth taking a look at the transit maps to see if there are any hidden gems.</p>
]]></content:encoded>
  <category>public transport</category>
  
</item>
  
  </channel>
</rss>