<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://chameth.com/">
    <title>Chameth.com - posts like break-everything-fuzz-testing, debugging-beyond-the-debugger, docker-automatic-nginx-proxy, securing-all-the-things-with-1password, why-you-should-be-using-https</title>
    <subtitle>Personal homepage of Chris Smith</subtitle>
    <link href="https://chameth.com/feeds/posts/like/break-everything-fuzz-testing,debugging-beyond-the-debugger,docker-automatic-nginx-proxy,securing-all-the-things-with-1password,why-you-should-be-using-https/" rel="self"/>
    <link href="https://chameth.com/"/>
    <icon>https://chameth.com/favicon.png</icon>
    <updated>2026-01-29T00:00:00Z</updated>
    <id>https://chameth.com/</id>
    <author>
        <name>Chris Smith</name>
    </author>
    <entry>
        <title>An interesting Tailscale + Docker gotcha</title>
        <link href="https://chameth.com/tailscale-docker-gotcha/"/>
        <updated>2026-01-29T00:00:00Z</updated>
        <id>https://chameth.com/tailscale-docker-gotcha/</id>
        <content xml:lang="en" type="html">&lt;p&gt;As I’ve &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;written&lt;/a&gt; &lt;a href=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/&#34;&gt;about&lt;/a&gt; &lt;a href=&#34;https://chameth.com/exposing-game-servers-over-tailscale/&#34;&gt;before&lt;/a&gt;, I use &lt;a href=&#34;https://tailscale.com/&#34;&gt;Tailscale&lt;/a&gt; for a lot of things. I thought I had it set up in a reasonably secure manner, but I recently noticed a problem.&lt;/p&gt;
&lt;p&gt;I use Tailscale’s ACLs to limit what each node can access, based on the tags I apply to it. So an &lt;code&gt;app&lt;/code&gt; node can’t access anything via Tailscale, while an &lt;code&gt;integration&lt;/code&gt; or &lt;code&gt;server&lt;/code&gt; node can access things tagged with either &lt;code&gt;app&lt;/code&gt; or &lt;code&gt;integration&lt;/code&gt;. This is expressed pretty simply in the Tailscale ACL JSON:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tagOwners&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Servers that can be SSH&amp;#39;d into
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Applications that are exposed on tailscale but never connect out
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:app&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Things which talk to other services over tailscale (connecting to apps etc)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;grants&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Users can access everything
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:member&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Servers and integrations can access integrations and apps
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:app&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Over the past week I’ve been setting up a private &lt;a href=&#34;https://forgejo.org/&#34;&gt;Forgejo&lt;/a&gt; instance behind Tailscale, complete with an actions runner that runs things using a docker-in-docker container. I didn’t want the runner knowing anything about Tailscale, so I had it configured to speak to Forgejo direct over HTTP (&lt;code&gt;forgejo:3000&lt;/code&gt;) instead of using full Tailscale HTTPS URL that I use when accessing it (&lt;code&gt;http://git.example-net.ts.net/&lt;/code&gt;)&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;Everything was going fine, until I forgot to do that translation… and it worked. My Forgejo action runners could access anything on my tailnet. I run several things on Tailscale that just have authentication turned off, on the basis that only authorised devices can access them. Things like the admin interface for this website. I definitely didn’t intend for any workflow I run on my git server to have access to edit my website!&lt;/p&gt;
&lt;p&gt;This wasn’t just limited to Forgejo, either. Any docker container I was running could access the tailnet. In hindsight it’s fairly obvious why: the host is running Tailscale, connected as a node tagged with my &lt;code&gt;server&lt;/code&gt; tag. That creates a &lt;code&gt;tailscale0&lt;/code&gt; interface, and automagically sets up iptables rules to route Tailscale traffic over the interface. Docker also automagically sets up iptables rules to bridge traffic, and apparently these two sets of rules interact in such a way that traffic from Docker containers is allowed to route via the &lt;code&gt;tailscale0&lt;/code&gt; interface.&lt;/p&gt;
&lt;p&gt;I say it’s fairly obvious in hindsight — there’s no reason why Docker would special case any particular host interface after all — but it still feels pretty surprising. Because both bits of software inject their own iptables rules, I never really had a good mental model for how they interact. The host Tailscale node was a completely separate building block to Docker. It would be a pain to use either of them if they didn’t do these rules, but it’s also one of the reasons I don’t really like “magical” things&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;There are lots of ways to fix this, but none of them feel particularly great. You can configure both Tailscale and Docker to not automatically fiddle with iptables and handle the rules yourself, but I really hate dealing with iptables&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;. As a stopgap I did hold my nose and add some iptables rules to drop traffic to the &lt;code&gt;tailscale0&lt;/code&gt; interface if it originated from the IP ranges that Docker was configured to use:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 192.168.0.0/16 -o tailscale0 -j DROP
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 172.17.0.0/12 -o tailscale0 -j DROP
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;DOCKER-USER&lt;/code&gt; chain is a nice little escape hatch; it comes before the main auto-generated &lt;code&gt;DOCKER&lt;/code&gt; chain, and Docker leaves the rules in it alone.&lt;/p&gt;
&lt;p&gt;So I added these rules, and felt pretty good about myself, and then… everything started breaking in weird ways. After some debugging I realised the problem was DNS&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;. The server’s DNS resolver is &lt;code&gt;100.100.100.100&lt;/code&gt;, a special Tailscale address. This is so that it can resolve tailnet hostnames via &lt;a href=&#34;https://tailscale.com/kb/1081/magicdns&#34;&gt;MagicDNS&lt;/a&gt; (and so I don’t have to configure my custom DNS servers manually on each device; Tailscale does it for me). My new iptables rules inadvertently dropped all the DNS packets coming from docker containers. D’oh.&lt;/p&gt;
&lt;p&gt;Obviously the solution here is to double down and add MORE iptables rules:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 192.168.0.0/16 -d 100.100.100.100 -o tailscale0 -j ACCEPT
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 172.17.0.0/12 -d 100.100.100.100 -o tailscale0 -j ACCEPT
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This got DNS working again, but it all feels a bit gross. Not least because to actually make these persist you need to use &lt;code&gt;iptables-save&lt;/code&gt;, and then you also get all the rules that both Docker and Tailscale have inserted. I came across &lt;a href=&#34;https://github.com/giangi/iptables-docker-filter&#34;&gt;a script&lt;/a&gt; to filter out the Docker ones, but… yuck&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;I realised a cleaner idea might just be to lock down what &lt;code&gt;server&lt;/code&gt; tagged nodes could access within Tailscale itself. That way I can avoid touching iptables at all. It’s handy to be able to &lt;code&gt;curl&lt;/code&gt; services from the host when debugging, but it’s not really necessary. So I removed that access, and… stuff broke again. I’m using my Forgejo instance as a registry for some of the docker images I run, so the docker daemon needs to be able to reach it. I ended up making a new tag for &lt;code&gt;infrastructure&lt;/code&gt;, which can be accessed from &lt;code&gt;server&lt;/code&gt; devices. This does still allow all the docker containers to reach Forgejo, but I already have it set up with appropriate access controls and public/private repository splits. Forgejo is a service designed to run publicly, so this seems a reasonable trade-off for convenience. I used the &lt;code&gt;tests&lt;/code&gt; feature of Tailscale’s ACL config to make sure I’d got the rules right:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tests&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Servers can only access infrastructure
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;   &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;proto&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tcp&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;allow&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:infrastructure:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;deny&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;100.84.16.43:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;me@example.com:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:app:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The whole situation still feels a bit messy. If I ever get around to switching to nftables I might loop back and manually craft some rules for routing traffic, instead of leaving Tailscale and Docker to do their own thing.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;I discovered later on that logging into the container registry at &lt;code&gt;forgejo:3000&lt;/code&gt; actually issued a redirect to &lt;code&gt;https://git.example-net.ts.net/&lt;/code&gt; so this was all basically for naught… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;I didn’t use Tailscale’s MagicDNS for a long time just because the word “magic” put me off. Only when I eventually got around to learning how it worked, and seeing that it wasn’t really that magical under the hood, did I change my mind. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;nftables seems far better in lots of ways, but I can’t really be bothered migrating. Maybe next time I reimage the server for whatever reason… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;It’s always DNS… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;Again, nftables would almost certainly help here. It actually has (gasp) configuration files. But again, I really didn’t want to spend the time migrating. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>Securing all the things with 1Password</title>
        <link href="https://chameth.com/securing-all-the-things-with-1password/"/>
        <updated>2025-11-26T00:00:00Z</updated>
        <id>https://chameth.com/securing-all-the-things-with-1password/</id>
        <content xml:lang="en" type="html">&lt;p&gt;For many years I’ve been a keen user of &lt;a href=&#34;https://bitwarden.com/&#34;&gt;Bitwarden&lt;/a&gt;. Recently I’ve had a lot of small paper-cut problems. The browser extension was redesigned and just doesn’t quite work how I expect any more. The prompt to save new login info misfired more than it worked. The mobile app stopped background refreshing properly. No one issue was enough to make me want to leave Bitwarden, but it definitely wasn’t sparking joy. Then there were rumblings about a price increase. I don’t think they’ve announced anything, but the numbers I saw were a jump from $10/year to $24/year. A nearly 2.5x price increase for a tool that was actively getting worse over time did not seem like a good deal to me&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;I spent a while looking at alternatives, but there were very few that met my needs. &lt;a href=&#34;https://nordpass.com/&#34;&gt;Nordpass&lt;/a&gt; is highly praised but doesn’t support saving TOTP codes on personal plans&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;. All the nice open-source/self-hosted solutions fall down when you want reliable synchronisation to an iOS device (which is fair enough). &lt;a href=&#34;https://proton.me/pass&#34;&gt;Proton Pass&lt;/a&gt; looked like it had all the features, but the pricing was asinine. Different pages showed different prices, the prices were introductory rates that jumped up on renewal, but that was hidden away in small print, etc. Yuck. The prices are also pretty high. More or less the same as &lt;a href=&#34;https://1password.com/&#34;&gt;1Password&lt;/a&gt;, which I’d been discounting due to the cost, until now…&lt;/p&gt;
&lt;p&gt;After eliminating basically every other option, I took a serious look at 1Password. It seemed to tick all the right boxes, I’d often heard good things about it, and it wouldn’t actually be much more expensive than Bitwarden if they went ahead with the rumoured price increase. I started a trial to give it a test.&lt;/p&gt;
&lt;h3 id=&#34;initial-impressions&#34;&gt;Initial impressions&lt;/h3&gt;
&lt;p&gt;1Password is — as you’d expect for something that originated as a Mac app — very pretty. But it’s also highly functional. The looks don’t get in the way, they’re nice extras on top. When it’s locked, you get a large version of the 1Password logo, which is a front-on view of the barrel of a lock, next to the password input. When you authenticate, the barrel turns as though you’d put a key in and rotated it, and then the background splits in two and opens like a vault door. Does a password manager need to do that? No. Does it make me a little bit happy every time I see it? Yes. Look at it, it’s amazing!&lt;/p&gt;
&lt;figure class=&#34;video full&#34;&gt;
  &lt;video src=&#34;https://chameth.com/securing-all-the-things-with-1password/1password-vault-opening.webm&#34; alt=&#34;Video of the 1Password app, showing the lock screen. As the password is submitted, the key barrel rotates, and the background parts like a vault opening.&#34; controls=&#34;&#34;&gt;&lt;/video&gt;
&lt;/figure&gt;
&lt;p&gt;The design philosophy carries through the rest of the app. Despite using Bitwarden daily, I often clicked on the wrong thing when I wanted to do anything other than autofill; in about a month of using 1Password I don’t think I’ve ever misclicked or been confused about the UI. The browser extension just &lt;em&gt;feels&lt;/em&gt; snappier than Bitwarden, too. I’m not sure if it’s actually faster or if the design just makes it feel that way.&lt;/p&gt;
&lt;p&gt;Functionality-wise, 1Password does everything Bitwarden did for me but slightly better. Bitwarden kicked you to its web interface for some operations (like checking compromised passwords); 1Password has that all built into the app. Instead of editing entries in a fiddly little browser popup, 1Password just summons forth the desktop app and you edit it there. It does a good job of detecting QR codes for TOTPs and offering to save them. I think the only small complaint I have is that the browser extension doesn’t have an autofill keybind. You can rig that up via the desktop app, but it feels like 1Password leans towards you using the UI it adds on login forms. To be fair, that UI works really well, and is very useful when you have multiple accounts on a site. My muscle memory for autofilling is strong, though.&lt;/p&gt;
&lt;h3 id=&#34;diving-deeper&#34;&gt;Diving deeper&lt;/h3&gt;
&lt;p&gt;At this point I’d decided to switch fully to 1Password, so I started digging into what else it could do. The “Watchtower” feature combines a bunch of reporting about passwords (weak, breached, reused, and so on), but also lists sites where 1Password knows you can use passkeys or 2FA and don’t have them set up. I’d previously disabled passkey support in Bitwarden because it just appeared one day and got in the way of me trying to use a YubiKey, but decided to give them a go with 1Password. “Passkeys” is basically just a marketing term for FIDO2’s WebAuthn with automatic discovery bolted on top. The big problem is how you synchronise or export them. It’s all well and good letting your iPhone create a passkey when the Amazon app arbitrarily prompts it to, but what happens when you log in on a different device, or swap to Android, or whatever? Having 1Password deal with them fixes that&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;The passkey flow is very smooth: 1Password pops up an account selector, with obvious options to create a new account or pass the request through to another device like a YubiKey. It’s smart enough to guess the right account most times, even when you’ve got 17 different Google accounts for &lt;code&gt;$reasons&lt;/code&gt;. Using them is equally simple: it just shows a popup and you tap to confirm you want to use the passkey. The Watchtower functionality made it really easy to go through and add passkeys everywhere that supports them. The thing I really appreciate is that they make the data that powers it &lt;a href=&#34;https://passkeys.directory/&#34;&gt;public&lt;/a&gt;. It’s obviously partially a marketing thing for them, but it’s also a useful resource they could have kept proprietary.&lt;/p&gt;
&lt;p&gt;Passkeys are a bit basic, though. 1Password has many more fun offerings. There’s an entire developer section with various goodies. At first I thought “that’s nice but I won’t use it”, but now I’m using basically everything it offers. The first thing to draw me in was the CLI. It’s pretty straightforward, but like the browser extensions it can nicely integrate with the desktop app so you unlock them all at once, deal with auth prompts in the GUI, and so forth. I have a couple of command-line tools that need passwords (e.g. for dealing with encrypted backups); now instead of prompting for the passwords, they shell out to &lt;code&gt;op read&lt;/code&gt; and I authorise it via a popup in the 1Password app. Simple, but a nice quality of life improvement.&lt;/p&gt;
&lt;p&gt;Next up there’s a beta feature called “Environments”. This lets you define groups of environment variables within 1Password, and have them exported automatically to certain destinations. At the minute it supports exporting to the AWS SecretsManager, and local &lt;code&gt;.env&lt;/code&gt; files. When making a local file it actually makes a named pipe, so the credentials aren’t just sitting on disk (and won’t end up accidentally committed to anything). When the 1Password vault is locked, anything trying to read from the file will cause an authentication popup, much like with the CLI tool. I used to have the mentality that if something could read arbitrary files on disk, it was basically “game over” from a security point of view. &lt;a href=&#34;https://www.bleepingcomputer.com/news/security/shai-hulud-malware-infects-500-npm-packages-leaks-secrets-on-github/&#34;&gt;Recent events&lt;/a&gt; made me realise there’s some value in defence-in-depth there, though, and 1Password provides a nice solution.&lt;/p&gt;
&lt;h3 id=&#34;fingerprints--linux--sadness&#34;&gt;Fingerprints + Linux = Sadness&lt;/h3&gt;
&lt;p&gt;One feature of 1Password had me feeling a bit left out. Instead of entering your vault password to unlock it every time, you can have it invoke the system authentication library. On an iPhone this uses Face ID, on a Mac it can invoke Touch ID, and on Linux it uses Polkit. In the default configuration, Polkit just asks for your local user password, which is not much of an improvement. A fingerprint scanner seemed like the obvious solution here: it’s both a lot more convenient than typing a long password, and provides some assurance that it’s actually me doing the action&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;. Unfortunately, fingerprint readers on Linux seem to be a bit of a sorry affair.&lt;/p&gt;
&lt;p&gt;Don’t get me wrong: there’s support. &lt;code&gt;libfprint&lt;/code&gt; has &lt;a href=&#34;https://fprint.freedesktop.org/supported-devices.html&#34;&gt;a big list of supported devices&lt;/a&gt;, but I found it almost impossible to actually find a device I could buy that had one of the listed sensors. Most of them seem to be exclusively built into laptops. A lot of the cheaper devices just use whatever sensor they can get their hands on, so it’s pot luck if you receive a version that will work with &lt;code&gt;libfprint&lt;/code&gt; or one that won’t. There’s also not a lot of information beyond that published by &lt;code&gt;libfprint&lt;/code&gt;. I guess it makes sense: external fingerprint readers just aren’t a thing most people care about. While that makes me feel all special, it’s somewhat prohibitive to getting an actual working solution.&lt;/p&gt;
&lt;p&gt;I’d given up on finding an answer to this when I stumbled upon &lt;a href=&#34;https://scottstuff.net/posts/2025/07/04/passwordless-ssh-and-sudo-with-1password/&#34;&gt;an article by Scott Laird&lt;/a&gt;. He uses a &lt;a href=&#34;https://www.yubico.com/products/yubikey-bio-series/&#34;&gt;YubiKey Bio&lt;/a&gt; to do it, via &lt;code&gt;pam_u2f&lt;/code&gt;. The Bio isn’t actually a fingerprint reader: it just has an on-board reader to secure the credentials. The OS doesn’t get involved in the process. But because it can provide a fingerprint-secured U2F key, and &lt;code&gt;pam_u2f&lt;/code&gt; can require that key to perform system operations, you basically get the same result. Maybe it’s actually even better than using a “real” fingerprint reader, as the data is secured on the hardware device…&lt;/p&gt;
&lt;p&gt;The YubiKey Bio was a bit more pricey than I really wanted, coming in at around £90, but it felt like a worthy investment to add a bit of security and avoid typing a password dozens of times a day. The setup was simple: the official app that you use to enrol fingerprints is packaged in the Arch User Repository, and it worked perfectly. I already had &lt;code&gt;pam_u2f&lt;/code&gt; set up as I used a (non-Bio) YubiKey to gate access to &lt;code&gt;sudo&lt;/code&gt;, so I just generated a new key on the Bio, updated my config to use that, and added it to the Polkit policy. Now when I need to unlock 1Password or authenticate something, it prompts me to scan my fingerprint. Perfect!&lt;/p&gt;
&lt;aside class=&#34;update raised-box&#34;&gt;
  &lt;h5 class=&#34;plain-header&#34;&gt;Update 2026-01-14:&lt;/h5&gt;
  &lt;p&gt;I used the YubiKey Bio for about a month, but I found it was a bit too rough for something I used so frequently. It would occasionally fail to read my fingerprints, and there’s no way to retry because of the massive indirection of polkit + pam + u2f. Even worse, it would sometimes fail enough times in a row that it’d lock itself, and have to be unlocked by opening the software. The Bio would be fine for securing something you used once or twice a day, but it’s not really a good alternative for a fingerprint reader you want to use on every git commit, SSH connection, desktop unlock, etc. It’s not a failure of the product, more of the usecase I tried to wrench it into.&lt;/p&gt;
&lt;/aside&gt;
&lt;h3 id=&#34;biometric-all-the-things-o&#34;&gt;Biometric all the things \o/&lt;/h3&gt;
&lt;p&gt;One of the other things Scott mentioned in that article was SSH keys. I’d seen 1Password supports acting as an SSH agent, but didn’t really see why I’d want it to. I used a resident key on my old YubiKey, so I didn’t have much to gain from a security point of view. Scott’s article made me realise I could have some big convenience gains, though. Currently every time I need to use the key, I enter a PIN and then physically touch it. I don’t mind this for the most part, but it turns out &lt;code&gt;git-lfs&lt;/code&gt; does something like four different requests every time you do a git operation, none of which reuse the same SSH connection for some reason. That gets painful when each request involves a PIN and a touch. I also liked the idea of using &lt;code&gt;pam_rssh&lt;/code&gt; for &lt;code&gt;sudo&lt;/code&gt; authentication, but didn’t want to have to do the PIN-and-touch dance every time I used it.&lt;/p&gt;
&lt;p&gt;You can probably see where this is going: I generated an SSH key in 1Password, and then… spent literal hours disabling all the other SSH agents that were running on my system, and trying to figure out where &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; was being populated. I didn’t manage to figure that out in the end&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;, and ended up just overriding it to point at 1Password’s agent socket. I added the new key to GitHub, and did the usual &lt;code&gt;ssh git@github.com&lt;/code&gt; test, and it worked perfectly. 1Password prompts to allow access to the key, showing the process requesting access, and authorising it requires a fingerprint read:&lt;/p&gt;
&lt;figure class=&#34;image center&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/securing-all-the-things-with-1password/1password-access.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/securing-all-the-things-with-1password/1password-access.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/securing-all-the-things-with-1password/1password-access.png&#34; alt=&#34;1Password dialog showing Kitty requesting access to the &amp;#34;Git&amp;#34; SSH key. There are &amp;#34;Deny&amp;#34; and &amp;#34;Authorise&amp;#34; buttons at the bottom; the authorise button has a fingerprint icon on it.&#34; loading=&#34;lazy&#34; width=&#34;412&#34; height=&#34;381&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;can haz ssh key?&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I’m not sure why Kitty doesn’t get an icon in the prompt; other applications do. You can expand the details section to see the key fingerprint being requested, as well as the exact process name and PID requesting the key. With the proof of concept done, I generated a couple more keys: one for normal SSH, and one for gaining root access using &lt;code&gt;pam_rssh&lt;/code&gt;. Using different keys ensures I get prompted for them separately, and means a malicious app couldn’t sneakily ask for a Git key but actually pivot to SSH into a server. Not sure that’s ever likely to be a problem, but it’s basically no additional work for a bit more defence-in-depth, so why not?&lt;/p&gt;
&lt;p&gt;I also took a moment to configure &lt;code&gt;pam_u2f&lt;/code&gt; for both &lt;code&gt;lightdm&lt;/code&gt; and &lt;code&gt;xfce4-screensaver&lt;/code&gt;, so I can now log in and unlock my PC with my fingerprint. It’s not quite as smooth as Touch ID on a Mac, but it’s pretty close.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;As an aside: this is not the first time I’ve been in this situation. Both Spotify and Todoist have focused on features I don’t want or need (including, but not limited to, pretty much everything branded “AI”), and then hiked the price up afterwards. It’s not quite standard enshittification, but it’s clear that our incentives no longer align properly. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;Or didn’t, at the time I looked. It now says “All plans” on the website but the docs only exist for business accounts, and there doesn’t seem to be any announcement explaining the change. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;Having Bitwarden deal with them would also have fixed that, but it got in my way instead of helping me, so… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;or it’s not me, but I’ll be busy dealing with the presumably-undesired removal of one of my digits, so probably won’t care about whatever is happening on the computer. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;Although I’m pretty sure the answer starts and ends with “systemd, somehow”. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>10 Weeks with an Apple Watch 10</title>
        <link href="https://chameth.com/10-weeks-with-an-apple-watch-10/"/>
        <updated>2025-09-09T00:00:00Z</updated>
        <id>https://chameth.com/10-weeks-with-an-apple-watch-10/</id>
        <content xml:lang="en" type="html">&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/watch.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/watch.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/watch.jpg&#34; alt=&#34;An Apple Watch 10 being worn, with a blue analogue clock on the display, and icons/data shown in the corners&#34; loading=&#34;lazy&#34; width=&#34;500&#34; height=&#34;644&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;My watch. Yes, I am available for wrist modelling opportunities.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Around ten weeks ago&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; I picked up an Apple Watch 10, and have been wearing
it almost constantly since. It’s not my first Apple Watch — I had a Series 5
for a bit back in 2020 — but it’s the first time I’ve actually stuck with it.
Ten weeks seems like an apt time to reflect on it.&lt;/p&gt;
&lt;p&gt;Firstly, why did I even bother? Well, for a couple of years I’d been wearing
a Xiaomi Smart Band 7, mainly to monitor my sleep stats and set alarms that
won’t wake up everyone else nearby. Its battery life was fantastic — with
notifications and other things turned off, I got about a month of use between
charges — but actually using it felt like trying to order food via the medium
of interpretive dance.&lt;/p&gt;
&lt;p&gt;My biggest gripe was the screen lock. If I didn’t have the screen locked then
I’d periodically trigger it during the night when I moved around
and it came in contact with my chest or leg. With the lock enabled you had to
deliberately swipe up from bottom to top to enable interaction, but it
didn’t work reliably. When I wanted to adjust an alarm, I’d be stood swiping
repeatedly trying to get it to respond. When you finally get it unlocked, the
whole interface is just &lt;em&gt;fiddly&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The other issue was the data quality. There were some nights when I’d been
woken up, sometimes even getting up and moving around, and it just didn’t show
it in the data. If it can’t even get whether I’m asleep right, can I trust
anything else it says?&lt;/p&gt;
&lt;p&gt;I spent a while researching the best devices for sleep tracking. The Oura ring
came highly recommended, but it was expensive and required a subscription to
do anything useful. No thanks! The Apple Watch was consistently rated pretty
well, and I reasoned I could pick up a refurbished older unit. I’ve been
&lt;a href=&#34;https://chameth.com/apple-google-aligned-incentives/&#34;&gt;using an iPhone as my daily driver&lt;/a&gt; for
a while, so it’d fit right into my begrudged walled garden.&lt;/p&gt;
&lt;p&gt;The Series 10 has a significant advantage, though: it charges much quicker than
all the previous generations. On a 30-minute charge, the Series 10 can go from
0 to 60%; the 9 can only make it to 40%, and my old 5 a measly 30%. Shorter
charge times means I’m far less likely to leave it on charge and wander off
without it. In some ways the daily charging is more convenient than monthly: the
wireless charger sits on my desk, and I plop the watch on it for a little while
in the evening; I don’t need to dig out the weird pogo-pin connector that has
vanished sometime in the last four weeks, then carefully arrange it so it stays
attached.&lt;/p&gt;
&lt;h3 id=&#34;how-a-watch-maybe-saved-my-life&#34;&gt;How a watch maybe saved my life&lt;/h3&gt;
&lt;p&gt;One of the big features of the Apple Watch, like many other wearable devices,
is health and fitness tracking. I didn’t think much about this, beyond the
sleep data I wanted, at first. I’ve never had a particularly good relationship
with exercise&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;, but I do like some good statistics. I started going for
walks more often to get more data and see the graphs of VO2 max and HR recovery
gradually inch up. That wasn’t the most profound effect on my health, though…&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;The recent versions of the Apple Watch have a feature that monitors for sleep
apnea, a disorder where you don’t breathe properly during your sleep. I knew
I wasn’t sleeping great — that’s why I was paying attention to sleep tracking
data — but was still a bit surprised to get a notification from Apple Health
after wearing the watch for 30 days. It gives you a graph to print and take to
your doctor. So I diligently booked an appointment and a few weeks later went
to see my GP.&lt;/p&gt;
&lt;p&gt;The appointment went about as you’d expect: talking about referral to a sleep
centre for a study, and so on. Towards the end, the doctor took my blood
pressure (sleep apnea can be caused by, and can cause high blood pressure, in
a lovely little vicious cycle). I don’t think either of us expected anything;
it was just one of those standard checks for a related problem. After taking
the reading, the doctor looked at me and said, “I can’t let you leave with a
BP like this”. Yikes!&lt;/p&gt;
&lt;p&gt;Blood pressure readings are split into stages: normal is under 120 mmHg&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;
over 80 mmHg&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;, stage 1 is up to 140/90, stage 2 is up to 180/120,
and above is simply called “crisis”. Guess where I was? Also, fun fact:
depending on how exactly you count and attribute things, hypertension comes out
as the single largest cause of death in humans. It doesn’t kill you outright,
but contributes to strokes, coronary artery disease, heart failure, and lots
of other lovely things you don’t want on your CV.&lt;/p&gt;
&lt;p&gt;After doing a few more readings, it settled down to just under the “crisis”
stage and into the “holy crap, start treatment immediately” stage instead.
I won’t labour on much more about this, but things are definitely moving in
the right direction now&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;While the Apple Watch didn’t literally save my life, it triggered the chain of
events that led to this diagnosis and treatment. Who knows what would have
happened had it remained undetected? Probably nothing good. Also, go check your
blood pressure! It’s simple to do and simple to treat if there is an issue,
but so many people are walking around with hypertension and not even realising.&lt;/p&gt;
&lt;aside class=&#34;update raised-box&#34;&gt;
  &lt;h5 class=&#34;plain-header&#34;&gt;Update 2025-09-09:&lt;/h5&gt;
  &lt;p&gt;Just a couple of hours after I published this post, Apple announced that they’re
adding hypertension notifications in the new Series 11 watch. It looks like
it will also be supported in Series 9 and Series 10 watches. They expect it to
notify more than one million people of unknown hypertension in the first year.&lt;/p&gt;
&lt;/aside&gt;
&lt;h3 id=&#34;building-gates-in-the-walled-garden&#34;&gt;Building gates in the walled garden&lt;/h3&gt;
&lt;p&gt;Even though the watch is arguably a life-saver, not everything is rosy.
It’s an Apple product, so you’re firmly locked in a walled garden, jealously
guarded by people wearing black turtlenecks. Fortunately, there are a few ways
to make it less painful.&lt;/p&gt;
&lt;p&gt;All the health and fitness data is stored in Apple Health. You can export data
as a big messy file, but it’s a very manual process to do so and the data format
is gnarly. Luckily, there’s an app for that!
&lt;a href=&#34;https://www.healthyapps.dev/&#34;&gt;Health Auto Export&lt;/a&gt; can, well, automatically
export health data. It does what it says on the tin. It can send the data to
Home Assistant, over MQTT, or dump it in some cloud file services, but I just
have it post it all to a REST endpoint on a service I wrote. Then I dump it all
in a database and can do whatever I want with it!&lt;sup id=&#34;fnref:6&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:6&#34; role=&#34;doc-noteref&#34;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Another tool that’s more useful on the watch itself is Apple Shortcuts. This
is their no-code “if-this-then-that”-type thing. You can make automations or
shortcuts that run a number of tasks. I have a whole slew of them I access
via a complication&lt;sup id=&#34;fnref:7&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:7&#34; role=&#34;doc-noteref&#34;&gt;7&lt;/a&gt;&lt;/sup&gt; on my watch face: one which prompts for input and adds an
item to my to-do list (swiping to write letters is surprisingly not horrible),
one which lets me select from a bunch of pre-written ones (“charge kindle”,
“take laundry out in 1 hour”, type things), one to log my weight into Apple
Health, one which can open and close the blinds in my room, and so on. It’s
a surprisingly robust and easy-to-use system and offers just enough freedom
that I’m not constantly grating on the edge of the walled garden.&lt;/p&gt;
&lt;p&gt;Shortcuts being able to initiate arbitrary web requests is the real killer
feature for me. Anything I can’t do on the watch itself, I can just farm off
to a web server and connect it up with a shortcut. No need to learn Swift or
pay Apple for the privilege of being a developer! For a lot of things, like
controlling the blinds or adding to-do items, I already had a HTTP endpoint
available and exposed over Tailscale&lt;sup id=&#34;fnref:8&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:8&#34; role=&#34;doc-noteref&#34;&gt;8&lt;/a&gt;&lt;/sup&gt;. Adding it to the watch was just a case
of entering the right things in the Shortcuts app.&lt;/p&gt;
&lt;h3 id=&#34;daily-nitty-gritty&#34;&gt;Daily nitty-gritty&lt;/h3&gt;
&lt;p&gt;There are lots of other little bits and pieces that come up when using the watch
daily. I don’t think I can bundle them up into a pleasing narrative arc, so
instead please enjoy some disjointed paragraphs of observations.&lt;/p&gt;
&lt;p&gt;The Apple Watch has a lot of nagging abilities. It can notify you about your
fitness “rings”, prompt you to stand up every hour, count how many seconds
you wash your hands for, etc. I think I’d dislike these just on general
principle, but the way it does them is &lt;em&gt;so&lt;/em&gt; condescending it’s painful. I think
there’s probably a cultural divide issue here, but there is no way in British
English to say “Great job! You washed your hands for 30 seconds!” without
it sounding like you’re being amazingly sarcastic or like you’re talking to a
young child. So I turned all of that nonsense off. It’s meant to be a tool
not a wannabe life coach.&lt;/p&gt;
&lt;p&gt;You can access Maps directly on the watch and even do navigation. It works
really well. The navigation mode has some nice haptic feedback: it does a short
pulse as you’re approaching a turn, and then a long pulse at the actual turn.
I like it a lot more than having to dig out my phone or have the directions
read out. You get one pulse, glance down and see where you need to go, then it
reminds you a little later when it’s time to do it. It’s a delightful user
experience.&lt;/p&gt;
&lt;p&gt;Watchfaces aren’t quite so delightful. There’s a limited number of built-in
ones, and some are “exclusive” to the Ultra Series, and you can’t use them on
a peasant watch like a Series 10. Annoyingly, there’s not one that does
exactly what I want: a plain analogue clock with an inset date and four
complication slots around the outside. Instead, I have to use one of the slots
to show the date. There are third-party watchfaces, but they have issues.
Firstly, there’s no actual API for making watchfaces&lt;sup id=&#34;fnref:9&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:9&#34; role=&#34;doc-noteref&#34;&gt;9&lt;/a&gt;&lt;/sup&gt;, so what they do is
bodge it horribly by using a photo background that has fake widgets on it.
On top of that they’re almost universally subscription-based. Again, no
thanks&lt;sup id=&#34;fnref:10&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:10&#34; role=&#34;doc-noteref&#34;&gt;10&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;The issue I had with the Smart Band triggering when I was sleeping is solved
trivially on an Apple Watch, by virtue of it having a physical button in the
crown. When you put it in sleep mode, you have to double-press the crown to
unlock it before it’ll do anything else. It hasn’t misfired once while I’ve
been using it.&lt;/p&gt;
&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/sleep.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/sleep.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/10-weeks-with-an-apple-watch-10/sleep.png&#34; alt=&#34;A graph showing sleep phases over time. There&amp;#39;s a noticeable transition form patchy data to more smooth data.&#34; loading=&#34;lazy&#34; width=&#34;284&#34; height=&#34;597&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Sleep data; spot when I swapped devices!&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Finally, a special mention for the gesture controls. If you raise the watch
it reliably wakes up (switching from a low refresh rate, dimmed screen to
an active, brighter one); you can then double-tap your index finger and thumb
together and it will scroll down or page through whatever you’re looking at.
The killer feature for this is navigating recipes: you can advance to the next
step in a recipe while your hands are covered in flour. It’s also handy for
reading notifications: when one pops up, you can double-tap to expand it, then,
when it gets to the bottom, it’ll outline the default button (often “Dismiss”)
and you can double-tap again to click it.&lt;/p&gt;
&lt;h3 id=&#34;the-verdict&#34;&gt;The verdict&lt;/h3&gt;
&lt;p&gt;I normally don’t like writing an actual labelled conclusion, but it feels
like one is needed here! Overall, I’m happy with the watch. The daily
charging doesn’t bother me, the data gathered seems reliable, the health
monitoring has obviously paid dividends already, and the walled garden isn’t
&lt;em&gt;too&lt;/em&gt; chafing. It’s a straight upgrade over my old Smart Band, and I think
it was worth the cost.&lt;/p&gt;
&lt;p&gt;The original reason for getting the watch was better sleep tracking, though,
so how well did it do? I’m much happier with the data: it seems to more
accurately represent when I was awake in the night, and overall the sleep
phases just seem to make more sense. You can see in the graph that the old
data switched frequently between phases, and they didn’t quite line up for
some reason; towards the right when the Apple Watch is supplying the data
instead there’s a much more consistent pattern of sleep phases that repeat
over the course of the night.&lt;/p&gt;
&lt;p&gt;I’m not going to advocate that you go out and buy one, though. I know my
requirements and usage aren’t typical, and I’ve also not got experience with
any recent Android Wear alternatives or the new version of the Pebble watch
that’s coming soon. You should definitely get your blood pressure checked,
though!&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;OK, it’s more like 14 now, it’s taken a while for this post to make its
way from my brain into text. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;Exercise for the sake of exercising just seems so overwhelmingly
tedious and boring to me. And other types of exercise generally require
social interaction, coordinating with people, and so on. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;Who decided to use “millimetres of mercury displaced” as a unit? You
can’t just put random chemical symbols in units! That’s not how this works! &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;Blood pressure readings have two parts: systolic (the pressure when the
heart is beating) and diastolic (the pressure between those beats). They’re
generally presented with the systolic reading on top and the diastolic reading
below, and read as “X over Y”. Now you know what some of the random numbers
they shout in medical shows mean! Yay learning! &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;No thanks to how much salt is in everything. I’m pretty sure I’ve had at
least twice the recommended daily amount of salt in a single serving before.
Don’t even get me started on the things that are “low salt” but are still full
of sodium from other sources. I don’t have a problem with ionic compounds, I
have a problem with sodium! &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:6&#34;&gt;
&lt;p&gt;This mainly looks like drawing graphs that are slightly different to
the graphs in the Apple Health graphs, for reasons I’m not sure I can explain.
Making graphs is fun, OK? &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:6&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:7&#34;&gt;
&lt;p&gt;Complications are basically just home screen widgets, but with a fancy
name because they’re on a watch. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:7&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:8&#34;&gt;
&lt;p&gt;Tailscale actually causes me some problems here: everything works fine
when the watch is connected to my phone, as the phone handles the Tailscale
part, but if I’m not carrying my phone the watch will try to connect over
WiFi directly and doesn’t understand anything about Tailscale. It happens
infrequently enough that I’ll just live with it; it’s not much worse than
having no signal on a phone. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:8&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:9&#34;&gt;
&lt;p&gt;Yay walled gardens… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:9&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:10&#34;&gt;
&lt;p&gt;I don’t object to subscribing to things in general, but it has to be
something that’s worth the ongoing cost and offers something in return for
the subscription. A watchface doesn’t need enough ongoing maintenance to
justify subscribing to it, it’s just a cash grab. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:10&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>Finding an awkward bug with Claude Code</title>
        <link href="https://chameth.com/finding-an-awkward-bug-with-claude-code/"/>
        <updated>2025-06-30T00:00:00Z</updated>
        <id>https://chameth.com/finding-an-awkward-bug-with-claude-code/</id>
        <content xml:lang="en" type="html">&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/finding-an-awkward-bug-with-claude-code/confidence.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/finding-an-awkward-bug-with-claude-code/confidence.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/finding-an-awkward-bug-with-claude-code/confidence.png&#34; alt=&#34;Composite screenshot of 11 different Claude responses that are all very confident at having found the bug&#34; loading=&#34;lazy&#34; width=&#34;411&#34; height=&#34;264&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;The many times Claude thought it was right…&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I recently encountered a bug in one of my projects that I couldn’t immediately
figure out. It was an issue in &lt;a href=&#34;https://github.com/csmith/centauri&#34;&gt;Centauri&lt;/a&gt;,
my reverse proxy. After its config was updated, I noticed&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; it stopped serving
responses. Looking at the logs, I could see it was obtaining new certificates
from Let’s Encrypt for a couple of domains, but I’d designed it so that wouldn’t
block requests (or so I thought). After a few minutes of confusion, everything
went back to working.&lt;/p&gt;
&lt;p&gt;It felt like an issue with locking, but a quick glance didn’t show me anything
obvious. I decided to throw the problem at Claude Code and see how it did.
With some guidance&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt; it managed to identify the cause, but it was a bit
of a journey to get there. I’m going to share the prompts and responses,
as it was an interesting process.&lt;/p&gt;
&lt;h3 id=&#34;the-initial-prompt&#34;&gt;The initial prompt&lt;/h3&gt;
&lt;p&gt;I thought the issue might be quite obvious, and I’d just overlooked it.
Centauri isn’t that complex a project, after all. So I gave Claude a fairly
minimal prompt to set it off:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; Hello! Please examine this codebase. I have noticed an issue that when a
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  certificate is being renewed, the application seems to not serve any other
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  requests. I suspect there is a misplaced lock somewhere.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Can you see what&amp;#39;s happening? Think very hard.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Claude Code has &lt;a href=&#34;https://www.anthropic.com/engineering/claude-code-best-practices&#34;&gt;special hardcoded triggers&lt;/a&gt;
to engage “thinking” mode, which makes it take more time to think about
alternatives. The keywords are: “think”, “think hard”, “think harder”, and
“ultrathink”. I managed to not quite get the right incantation here, and only
enabled the lowest level&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;I’ll omit all the responses where Claude is “thinking” (talking to itself),
or decides to make todo lists then cross items off them then add different
items, and so on. Its responses get fairly lengthy; I won’t judge you too
harshly if you skim over them. Its first response was blessedly brief:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● I found the issue! Looking at the certificate manager code, I can see the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  problem:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;… and then it went back to thinking and reading files, because it had neither
found the issue nor could it see the problem. Ho-hum. Some time later it came
back with:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● I found the issue! The problem is in certificate/manager.go:53-54:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  m.store.LockCertificate(subject, altNames)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  defer m.store.UnlockCertificate(subject, altNames)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The GetCertificate method acquires a lock at the beginning and holds it for the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  entire duration of the certificate renewal process. This includes:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. Contacting the ACME server
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. Solving DNS-01 challenges (can take minutes for DNS propagation)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. Obtaining the certificate
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  4. Getting OCSP staple
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  5. Saving to disk
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  During this time, any other request trying to get the same certificate will be
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  blocked. While the lock is per-certificate (not global), it still means that all
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  requests for that specific domain are blocked during renewal.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Let me check if there are other places where this might cause blocking:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And it then proceeded to do even more thinking, checking files, etc, until
it concluded with:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● I found the root cause! The issue is actually worse than just a
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  per-certificate lock. Here&amp;#39;s what&amp;#39;s happening:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The main blocking issue is in proxy/manager.go:133-144:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  func (m *Manager) CheckCertificates() {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      m.lock.RLock()  // ← Acquires read lock on the entire manager
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      for i := range m.routes {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          route := m.routes[i]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          // ...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          m.updateCert(route)  // ← This can take MINUTES for ACME renewal!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      m.lock.RUnlock()  // ← Only released after ALL routes are processed
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The blocking cascade:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   1. CheckCertificates holds a read lock on the entire proxy manager while
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      renewing ALL certificates
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   2. Certificate renewal via ACME can take several minutes per certificate
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      (DNS propagation, etc.)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   3. Every incoming request calls routeFor (proxy/manager.go:122) which needs
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      the same lock:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    func (m *Manager) routeFor(domain string) *Route {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        m.lock.RLock()  // ← BLOCKED until CheckCertificates completes!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        match := m.domains[strings.ToLower(domain)]
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        m.lock.RUnlock()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        // ...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  So when certificate renewal starts, ALL incoming requests are blocked because
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  they can&amp;#39;t even look up which route to use. This explains why the application
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  doesn&amp;#39;t serve any requests during renewal - they&amp;#39;re all waiting for the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  manager&amp;#39;s read lock.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The issue is compounded by:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - Certificate renewal happens every 12 hours automatically
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - It also happens when routes are updated (SIGHUP)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - Each certificate renewal involves time-consuming ACME operations
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  This is a critical performance bug where I/O operations (certificate renewal)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  are performed while holding a critical lock.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This doesn’t actually follow on from the earlier response, it’s completely
flipped onto a different lock being the issue. Amusingly, it’s nearly right
here, but it’s highlighting this code for completely the wrong reason.&lt;/p&gt;
&lt;p&gt;The lock it’s talking about is a &lt;code&gt;RWMutex&lt;/code&gt;, which is specifically designed to
allow many read-locks. Everything it’s pointing out are read locks, so they
cannot block one another as it described. The response Claude has given sounds
very confident, seems appropriately technical, but is in fact complete rubbish.
This is one of the big issue with all LLMs at the minute, and it makes me
despair at the idea of people “vibe coding” things when they don’t know enough
to push back against nonsense like this.&lt;/p&gt;
&lt;h3 id=&#34;course-correction&#34;&gt;Course correction&lt;/h3&gt;
&lt;p&gt;I explained briefly to Claude how this basic concurrency primitive works,
and it came right back with another solution:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; But that&amp;#39;s a RW Mutex. You can hold many read locks concurrently.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● You&amp;#39;re absolutely right! I apologize for the confusion. With a RWMutex,
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  multiple readers can hold the lock concurrently. Let me trace through this
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  more carefully.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Task(Analyze updateCert method)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  ⎿ Done (8 tool uses · 20.0k tokens · 1m 11.9s)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Excellent analysis! You&amp;#39;re right about RWMutex allowing concurrent readers.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The real issue is the race condition in the certificate store&amp;#39;s lock
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  management.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Looking at certificate/store.go:82-91:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  func (j *JsonStore) lockFor(subjectName string, altNames []string) *sync.Mutex {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      key := strings.Join(append([]string{subjectName}, altNames...), &amp;#34;;&amp;#34;)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      if mu, ok := j.locks[key]; ok {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          return mu
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      } else {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          mu = &amp;amp;sync.Mutex{}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          j.locks[key] = mu  // ← Concurrent map write!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          return mu
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  This is a classic concurrent map access bug. When multiple requests hit the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  same domain during certificate operations:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. Multiple goroutines call lockFor() simultaneously
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. They read/write to j.locks map without synchronization
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. Go runtime can:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Panic with &amp;#34;concurrent map writes&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Corrupt the map structure
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Block indefinitely if map internals get corrupted
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  This explains the blocking behavior - it&amp;#39;s not the RWMutex, but rather a race
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  condition causing either panics or deadlocks when the map gets corrupted
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  during concurrent certificate operations.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The fix would be to either:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. Add a mutex to protect j.locks map access
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. Use sync.Map instead of a regular map
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. Pre-initialize all locks at startup
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I do appreciate when me stating a basic fact is called out as “excellent
analysis”. Definitely doesn’t make me feel like I’m having smoke blown up my
ass.&lt;/p&gt;
&lt;p&gt;I think the issue it’s describing here is actually a problem&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;, albeit one
that’s unlikely to be hit. But it’s definitely not the one we’re looking
for. Claude even describes the possible consequences of the bug, and they
don’t match what happened at all.&lt;/p&gt;
&lt;h3 id=&#34;maybe-logs-will-help&#34;&gt;Maybe logs will help?&lt;/h3&gt;
&lt;p&gt;While Claude was chugging away, I’d been looking into the issue as well. I
had logs from when it happened, and figured Claude may be able to glean
something from them too. The first thing I’d noticed is that I wasn’t logging
when a process finished, so I’d already added that to the code base, hence
the note so Claude doesn’t get itself confused:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; That may be an issue, but it&amp;#39;s not the one I was looking for. I&amp;#39;ve found logs
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  from the time it happened. Note that a few logs have been added to the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  codebase since (so we can&amp;#39;t tell from the logs when ACME or OCSP processes
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  *finished*, just when they started). Here are the logs:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:40:53.187887976Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Received signal, updating routes...&amp;#34;,&amp;#34;signal&amp;#34;:1}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:40:53.19947532Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:40:53.199507942Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:09.048465968Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:09.048482309Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:17.419299795Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:17.419325744Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:23.238027386Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Received signal, updating routes...&amp;#34;,&amp;#34;signal&amp;#34;:1}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:29.994330646Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:29.994353329Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:43.658794811Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:43.658819548Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:57.635325513Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:41:57.635347515Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:10.52439779Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:10.524417107Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:23.159430249Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:23.159452171Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:36.347407326Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:36.347433295Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:52.544628775Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:42:52.544653993Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:25.409579107Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:25.409602842Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:43.819479448Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:43.819502041Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:54.358957563Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:43:54.358979355Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:44:06.180278128Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Renewing certificate&amp;#34;, &amp;lt;big list of alt names&amp;gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:44:06.180302585Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;INFO&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Starting ACME process to obtain certificate&amp;#34;, &amp;lt;big list of alt names&amp;gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:45:03.830331922Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:45:03.830528134Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:45:03.832388931Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:46:10.225854865Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:46:10.488432434Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:46:10.488439397Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  {&amp;#34;time&amp;#34;:&amp;#34;22:46:30.617614536Z&amp;#34;,&amp;#34;level&amp;#34;:&amp;#34;WARN&amp;#34;,&amp;#34;msg&amp;#34;:&amp;#34;Failed to connect to upstream&amp;#34;,&amp;#34;error&amp;#34;:&amp;#34;context canceled&amp;#34;, ...}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I’ve cut down the logs a bit to remove the specific domains, simplify the
timestamps, and remove a bunch of other cruft. The key points are there were
two SIGHUPs received, and the last certificate before everything started breaking
covered 6 different hostnames.&lt;/p&gt;
&lt;p&gt;Claude then went through a phase of periodically shouting “Eureka!” then
deciding it hadn’t actually found anything yet:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Now I see it! The key insight is in the timing and the &amp;#34;context canceled&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  errors. Let me trace through what&amp;#39;s happening:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Ah, now I see what&amp;#39;s happening! Looking at the logs more carefully:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. 22:40:53 - First SIGHUP received, routes updated
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. 22:40:53 to 22:44:06 - Multiple certificates start renewing (14 certificates!)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. 22:41:23 - Another SIGHUP received while renewals are still in progress
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  4. 22:45:03 onwards - &amp;#34;context canceled&amp;#34; errors start appearing
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The issue is that SetRoutes is being called AGAIN (at 22:41:23) while the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  previous CheckCertificates() goroutine is still running and renewing
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  certificates. Let me check what happens in this scenario:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; (The context canceled errors are clients timing out because centauri wasn&amp;#39;t
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  proxying properly)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Ah, now I understand! The &amp;#34;context canceled&amp;#34; errors are from clients timing
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  out because Centauri wasn&amp;#39;t proxying their requests. This confirms the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  blocking issue.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Let me check how incoming requests actually get their certificates:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Now I found the real issue! Looking at the timeline and the code:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. 22:40:53 - SIGHUP triggers SetRoutes() which:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Calls loadCertificate() for each route
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Sets route.certificate to the existing certificate
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Starts go m.CheckCertificates() in background
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. 22:41:23 - Another SIGHUP while certificates are still renewing
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - SetRoutes() is called AGAIN
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Creates NEW Route objects with route.certificate = nil initially
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Replaces the old routes in m.routes
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - The old Route objects (with valid certificates) are replaced!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The critical issue is in proxy/manager.go:42-65:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  func (m *Manager) SetRoutes(newRoutes []*Route, fallback *Route) error {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      // ... validation ...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      for i := range newRoutes {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          route := newRoutes[i]  // These are NEW Route objects!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          // ...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;          m.loadCertificate(route)  // Try to load certificate
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      m.lock.Lock()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      m.routes = newRoutes  // REPLACES all routes!
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      m.lock.Unlock()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  When the second SIGHUP arrives:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. New Route objects are created
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. For domains currently being renewed, GetExistingCertificate might fail or
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     return expired certs
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. These routes get route.certificate = nil and CertificateMissing status
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  4. CertificateForClient returns nil for these domains
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  5. TLS handshake fails → clients can&amp;#39;t connect → &amp;#34;context canceled&amp;#34; errors
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The fix would be to either:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. Prevent concurrent CheckCertificates runs
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. Copy certificates from existing routes before replacing them
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. Make certificate renewal truly independent of route management
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Midway through it had started searching for “context canceled” errors in the
codebase, which doesn’t make much sense. That’s what happens when a client gives
up waiting, so the corresponding backend request is terminated. You won’t
actually find “context canceled” anywhere in Centauri, because it’s a feature
of the Go standard library. I gave it a little nudge to try to stop it rabbit
holing.&lt;/p&gt;
&lt;p&gt;As far as I can tell, what it’s talking about in this response isn’t an issue.
I guess it’s possible that there’s a race condition whereby a route would be
replaced and end up with an old version of a certificate that was just being
renewed, but it wouldn’t cause an issue in practice; it’d just get updated
next time the certificates were refreshed.&lt;/p&gt;
&lt;p&gt;There’s no way for routes to get a &lt;code&gt;nil&lt;/code&gt; certificate in this scenario, as far
as I know. But I don’t have the unfounded confidence of an LLM, so I simply
said:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; I don&amp;#39;t follow why the certificate would be nil?
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;attempt-the-third&#34;&gt;Attempt the third&lt;/h3&gt;
&lt;p&gt;I expected Claude to just explain its reasoning, but it apparently didn’t have
any reasoning so set off on another voyage of discovery:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● You&amp;#39;re right to question that. Let me trace through this more carefully.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Good point! Since GetExistingCertificate doesn&amp;#39;t acquire locks, it should
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  return the existing certificate even during renewal. Let me dig deeper:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● Now I see a potential race condition! Here&amp;#39;s what could happen:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● I found it! There&amp;#39;s a critical race condition that explains the blocking:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The Race Condition
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. Background renewal starts: CheckCertificates() → updateCert() →
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     GetCertificate() acquires lock and starts ACME renewal
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. SIGHUP arrives: SetRoutes() is called, which for each route calls
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     loadCertificate() → GetExistingCertificate() → store.GetCertificate()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. The race:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - store.GetCertificate() returns a pointer to the certificate in the slice
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Meanwhile, the renewal completes and calls SaveCertificate()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - SaveCertificate() calls removeCertificate() which modifies the slice while
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      it&amp;#39;s being read
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - This slice modification (append(j.certificates[:i], j.certificates[i+1:]...))
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      can cause:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        - The pointer returned by GetCertificate to become invalid
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        - Slice bounds issues
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;        - Memory corruption
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  4. The result:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - GetExistingCertificate might return corrupted data or nil
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Routes end up with certificate = nil
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - TLS handshakes fail
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    - Clients timeout → &amp;#34;context canceled&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The Timeline Confirms This
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - 22:40:53 - First SIGHUP, starts renewal of 14 certificates
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - 22:41:23 - Second SIGHUP arrives (30 seconds later) while renewals are still running
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - 22:45:03 - First timeout errors (4 minutes after first SIGHUP)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The 4-minute delay matches ACME renewal time. The second SIGHUP&amp;#39;s SetRoutes
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  likely read certificates while they were being modified by the first SIGHUP&amp;#39;s
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  renewal goroutines.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  This is a classic concurrent slice modification bug - no synchronization
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  between readers (GetCertificate) and writers (SaveCertificate/removeCertificate).
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Once again we have a very confident and technical answer that does not explain
the symptoms we’re looking for. It’s also mostly rubbish again.
&lt;code&gt;GetCertificate&lt;/code&gt; and &lt;code&gt;SaveCertificate&lt;/code&gt; are only called by code that is holding
a lock for that certificate; they won’t interfere with one another. There may,
again, be a small concurrency issue if different certificates are handled in
parallel, but it’s definitely not the cause of our current problems.&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Working through these explanations and debunking them was annoying me at this
point. I decided to give it once last chance, and used the “ultrathink”
shibboleth:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&amp;gt; So there may be a concurrent modification issue, but that doesn&amp;#39;t explain the
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  symptoms. If a TLS handshake failed it wouldn&amp;#39;t result in the clients timing
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  out. Likewise if there was any kind of corruption there&amp;#39;d be a panic, not a
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  period where it didn&amp;#39;t work and then started working again. Engage ultrathink.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;fourth-time-lucky&#34;&gt;Fourth time lucky&lt;/h3&gt;
&lt;p&gt;I’m going to include a piece of the thinking here because it amused me so much:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;✻ Thinking…
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Oh! I need to think about this differently. Let me trace through what happens
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  when a request comes in:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Apparently it takes “ultrathink” to actually work through the problem logically?
Amusingly it did actually get there:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● You&amp;#39;re absolutely right. Let me think more carefully about this...
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;● EXACTLY! That&amp;#39;s the issue. Looking at the timeline:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  1. 22:40:53 - First SIGHUP: CheckCertificates() acquires READ lock, starts
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     renewing 14 certificates
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  2. 22:41:23 - Second SIGHUP: SetRoutes() tries to acquire write lock, blocks
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     waiting
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  3. Incoming requests call routeFor() for route lookup, but Go&amp;#39;s RWMutex blocks
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     new readers when a writer is waiting (to prevent writer starvation)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  4. 22:45:03 - Clients timeout because requests can&amp;#39;t even get past route lookup
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The key insight is Go&amp;#39;s RWMutex writer starvation prevention: when a writer
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  is waiting, new readers are blocked even though the current lock holder is
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  also a reader.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  So the cascade is:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - CheckCertificates (read lock) → certificate renewal takes minutes
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - SetRoutes (write lock) → waits for read lock to release
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - routeFor (read lock for every request) → blocked by waiting writer
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  Result: All incoming HTTP requests hang at route lookup, never reach TLS
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  handshake or proxying logic. Clients timeout waiting for any response
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  → &amp;#34;context canceled&amp;#34;.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  This perfectly explains:
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - No panics (no corruption)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - No immediate TLS errors (never gets that far)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - Requests hang then timeout (blocked on lock)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  - Eventually recovers (when certificate renewals finish)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  The fix would be restructuring to avoid holding the manager lock during slow
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  certificate operations.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We’re back to the &lt;code&gt;RWMutex&lt;/code&gt; again, but this time it’s dropping knowledge on me
not the other way around: if there’s a writer waiting, then new readers are
blocked. This does indeed perfectly explain the symptoms, and was something
I’d overlooked. The write lock is only held briefly to update some struct
members to prevent it from blocking the readers, but this behaviour coupled
with the long-running read lock ends up doing that anyway.&lt;/p&gt;
&lt;h3 id=&#34;the-fix&#34;&gt;The fix&lt;/h3&gt;
&lt;p&gt;After all this work it’s very tempting just to ask Claude to fix it, but I
had almost no confidence it’d be able to maintain any of the concurrency
requirements given how much it flailed around with bad explanations. But in the
interests of science, I asked it to try. Here’s what it came up with:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   // CheckCertificates checks and updates the certificates required for registered routes.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   // It should be called periodically to renew certificates and obtain new OCSP staples.
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   func (m *Manager) CheckCertificates() {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    // Create a snapshot of routes to avoid holding the lock during slow certificate operations
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     m.lock.RLock()
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    routes := make([]*Route, len(m.routes))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    copy(routes, m.routes)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    provider := m.provider
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    m.lock.RUnlock()
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    // Process certificate renewals without holding the manager lock
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gd&#34;&gt;-    for i := range m.routes {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+    for i := range routes {
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gd&#34;&gt;-      route := m.routes[i]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gi&#34;&gt;+      route := routes[i]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;       if provider == nil {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;         route.certificateStatus = CertificateNotRequired
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;       } else {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;         m.updateCert(route)
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;       }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;     }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-gd&#34;&gt;-    m.lock.RUnlock()
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   }
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;   // updateCert updates the certificate for the given route.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That’s about what I expect. It definitely fixes the exact issue described, but
it’s a bit… sloppy. Routes are now being updated when the lock isn’t being held,
which is a change in semantics. It also doesn’t address the underlying issue
that requests can be blocked if there’s a pending writer, and pending writes
can be blocked by readers. A better approach would be to allow these operations
to happen without blocking, maybe by using an atomic pointer to an immutable
slice. I guess I’ve got some coding to do!&lt;/p&gt;
&lt;p&gt;As for Claude: while the journey was frustrating, I think trying to figure this
out by myself would have been even more so. It’s one of those things where
everything &lt;em&gt;looks&lt;/em&gt; right to me, and I’d have to go down a fairly long chain
of checking my assumptions to find where I’d gone wrong.&lt;/p&gt;
&lt;p&gt;There’s definitely a lot of room for improvement in coding LLMs: being able
to estimate their own confidence level instead of stating everything as
absolute truth would go a long way. I imagine there’s also a lot of benefit
of having one LLM vet the responses of another; that’s sort-of how thinking
mode works, but it doesn’t seem to go far enough to stop it outputting rubbish.
It’ll be interesting to see how they develop in the future, anyway.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;read as “I got spammed by a bunch of monitoring notifications” &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;read as “Me repeatedly telling it that it was talking rubbish” &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;If only we had some kind of language model technology that could understand this kind of thing. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;I sense some fun times with Go’s race detector in my near future &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;The most likely outcome from these concurrency issues is Centauri panicking,
which isn’t great but would only interrupt ongoing connections before it
restarts and starts serving traffic again. Much less problematic than the
“block for five minutes” behaviour I’m trying to fix. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>How I use Tailscale</title>
        <link href="https://chameth.com/how-i-use-tailscale/"/>
        <updated>2025-06-25T00:00:00Z</updated>
        <id>https://chameth.com/how-i-use-tailscale/</id>
        <content xml:lang="en" type="html">&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/logo.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/logo.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/how-i-use-tailscale/logo.png&#34; alt=&#34;The Tailscale logo: a 3x3 grid of dots, with the middle row and bottom middle dot in white, forming a T shape&#34; loading=&#34;lazy&#34; width=&#34;500&#34; height=&#34;494&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;The Tailscale logo&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I’ve been using &lt;a href=&#34;https://tailscale.com/&#34;&gt;Tailscale&lt;/a&gt; for around four years to connect my disparate devices,
servers and apps together. I wanted to talk a bit about how I use it, some cool
features you might not know about, and some stumbling blocks I encountered.&lt;/p&gt;
&lt;p&gt;I’m not sure Tailscale needs an introduction for the likely audience of this
blog, but I’ll give one anyway. Tailscale is basically a WireGuard&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;
orchestration service, with lots of nice features sprinkled on top. It’s a
subscription product, but it has an insanely generous free tier that covers
basically anything you’d ever want to do as an individual. They also open source
all their client software, and there’s a third party control server
implementation called &lt;a href=&#34;https://github.com/juanfont/headscale&#34;&gt;Headscale&lt;/a&gt; if you want to avoid the hosted system entirely.&lt;/p&gt;
&lt;h3 id=&#34;basic-connectivity&#34;&gt;Basic connectivity&lt;/h3&gt;
&lt;p&gt;At its core, Tailscale lets you easily connect from one device to another,
even if they’re not directly exposed to the Internet. You install the Tailscale
client wherever you like (on your phone, computer, servers, Raspberry Pi, etc),
authenticate the machine with the control server, and it can then talk to all
the other machines on the tailnet using their private Tailscale IP addresses.&lt;/p&gt;
&lt;p&gt;That isn’t anything revolutionary: it’s the basic premise behind VPNs. But
Tailscale makes it so easy. You don’t have to bother with any networking
configuration. You don’t have to distribute keys. You just install the
client&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;, and login.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;For example, my &lt;a href=&#34;https://chameth.com/home-automation-without-megacorps/&#34;&gt;home automation service&lt;/a&gt;
runs on a Raspberry Pi that sits behind two different routers. I installed
Tailscale on it&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;, logged in, and immediately I could SSH into it from
my computer or my phone even when they’re on different networks.&lt;/p&gt;
&lt;p&gt;Speaking of SSH, Tailscale has special support for it whereby it handles any
incoming connection to port 22 from the Tailscale network, and deals with
authentication itself. No public keys or passwords: if you’re logged into
Tailscale you can be logged into the machine. This is particularly handy when
you SSH from a phone, as proper credential management is a bit of a nightmare
there.&lt;/p&gt;
&lt;p&gt;Exposing entire machines is just the start, though. If you run multiple services
on the machine you need to remember which port they’re all on. Yuck. Instead,
you can expose individual services as their own node on a tailnet. There
are lots of options for doing so: there’s &lt;a href=&#34;https://tailscale.com/kb/1282/docker&#34;&gt;an official Docker image&lt;/a&gt;,
a &lt;a href=&#34;https://pkg.go.dev/tailscale.com/tsnet&#34;&gt;Go library&lt;/a&gt;, and many third-party
tools (such as my own &lt;a href=&#34;https://github.com/csmith/centauri&#34;&gt;Centauri&lt;/a&gt; and &lt;a href=&#34;https://github.com/csmith/tsp&#34;&gt;tsp&lt;/a&gt;).&lt;/p&gt;
&lt;h3 id=&#34;not-just-a-vpn&#34;&gt;Not just a VPN&lt;/h3&gt;
&lt;p&gt;Exposing individual services wouldn’t be much use if you still had to remember
their IP addresses. For a long time I manually added DNS entries for Tailscale
nodes, allowing me to connect to &lt;code&gt;http://grafana/&lt;/code&gt; instead of
&lt;code&gt;http://100.73.190.100&lt;/code&gt;. Recently I switched to Tailscale’s &lt;a href=&#34;https://tailscale.com/kb/1081/magicdns&#34;&gt;MagicDNS&lt;/a&gt;,
though, which takes care of that for you. Every machine on the tailnet gets
an automatic DNS entry based on its name.&lt;/p&gt;
&lt;p&gt;I initially shied off of this because it changes the DNS resolver on every
machine it’s installed on, and it all seemed a bit too “magic” for me&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;. I got
fed up with manually adding DNS entries though, so I spent a bit of time
learning about it and got over my knee-jerk “what on earth is going on?!”
reaction. The DNS controls also let you force a specific upstream DNS server.
I use &lt;a href=&#34;https://nextdns.io&#34;&gt;NextDNS&lt;/a&gt; everywhere, so having Tailscale automatically
configure this for me on all my devices was a bonus&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;As well as the short names, you can resolve nodes on the tailnet using a
slightly longer form: &lt;code&gt;machine.your-tailnet.ts.net&lt;/code&gt;. The “your-tailnet” bit
is randomly generated, and you can reroll it in the control panel to find a fun
one. But why waste time say lot word, when few word do trick? Because Tailscale
can make these addresses globally routable, and issue TLS certs for them too.&lt;/p&gt;
&lt;p&gt;Say you’re developing something on your local machine, and want to get feedback
from someone. You can use Tailscale’s “funnel” feature to expose it publicly:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;tailscale funnel 127.0.0.1:8080
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;With no other options, this will expose the service over HTTPS on port 443.
You can then invite people to visit &lt;code&gt;https://machine.your-tailnet.ts.net&lt;/code&gt; and
they’ll connect through to port 8080 on your machine. They don’t even need
Tailscale installed. I don’t use this terribly often, but it’s a great
quality-of-life improvement on the occasions I do want to share something.&lt;/p&gt;
&lt;p&gt;Tailscale also has a “serve” command, which does much the same but only
exposes it over your tailnet. This is how you’d use the Tailscale Docker image
to expose a normal service onto your tailnet, but it’s also useful for similar
development scenarios. Occasionally I’ll want to test something on my phone,
and rather than make sure it’s connected to the right Wi-Fi network, recompiling
the app to not bind to localhost, figuring out my machine’s IP, and so on, and
so forth, I just run &lt;code&gt;tailscale serve&lt;/code&gt; and access it over Tailscale.&lt;/p&gt;
&lt;h3 id=&#34;authentication-two-ways&#34;&gt;Authentication, two ways&lt;/h3&gt;
&lt;p&gt;One of my minor frustrations with Tailscale was how cumbersome the authentication
process was. I logged in using GitHub, and every time I tried to login to
Tailscale, I had to bounce through the GitHub OAuth flow and then select which
organisation I wanted to use. Even if I was logged in to GitHub, it still shows
the permission page every time. It’s not a dealbreaker, but it’s definitely
not smooth. This is what it looks like:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/auth.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/auth.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/how-i-use-tailscale/auth.png&#34; alt=&#34;Screenshots of the Tailscale/GitHub login flow: (1) The Tailscale login form (2) The GitHub login form (3) GitHub permissions screen (4) Tailscale tailnet selection screen&#34; loading=&#34;lazy&#34; width=&#34;2000&#34; height=&#34;647&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Logging in to Tailscale with a GitHub account&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Tailscale have since added the ability to sign in with a custom OIDC provider,
so I set up a &lt;a href=&#34;https://www.keycloak.org/&#34;&gt;KeyCloak&lt;/a&gt; instance, and my login flow
now looks like this:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/keycloak.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/how-i-use-tailscale/keycloak.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/how-i-use-tailscale/keycloak.png&#34; alt=&#34;Screenshots of the Tailscale/KeyCloak login flow: (1) The Tailscale login form (2) The KeyCloak login form&#34; loading=&#34;lazy&#34; width=&#34;1145&#34; height=&#34;647&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Logging in to Tailscale with KeyCloak as an OIDC provider&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;It’s a significantly shorter flow, and because KeyCloak is entirely under my
control I can configure how long I stay logged in for. It also reduces my
reliance on third parties: if GitHub goes down, or decides to ban my account&lt;sup id=&#34;fnref:6&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:6&#34; role=&#34;doc-noteref&#34;&gt;6&lt;/a&gt;&lt;/sup&gt;
or something, I can still access Tailscale.&lt;/p&gt;
&lt;p&gt;That’s how you log in to Tailscale, but Tailscale can also assist with logging
in to other services. If you connect to a HTTP service over Tailscale, it will
add in the following headers:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-err&#34;&gt;Tailscale-User-Login: alice@example.com
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-err&#34;&gt;Tailscale-User-Name: Alice Smith
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-err&#34;&gt;Tailscale-User-Profile-Pic: https://example.com/photo.jpg
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A lot of software has support for accepting login credentials provided by a
reverse proxy, which is basically what’s happening here. For example, I run
a Grafana instance that’s only exposed over Tailscale. Its config (set
in environment variables) is:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_USERS_AUTO_ASSIGN_ORG&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_USERS_AUTO_ASSIGN_ORG_ROLE&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;Admin&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_AUTH_PROXY_ENABLED&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_AUTH_PROXY_HEADER_NAME&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;Tailscale-User-Login&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_AUTH_PROXY_HEADER_PROPERTY&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-nt&#34;&gt;GF_AUTH_PROXY_AUTO_SIGN_UP&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So anyone who presents a &lt;code&gt;Tailscale-User-Login&lt;/code&gt; header gets an admin account
created with that as their e-mail address. It’s super convenient, but obviously
make sure it’s &lt;em&gt;only&lt;/em&gt; exposed via Tailscale if you do that!&lt;/p&gt;
&lt;p&gt;Other services I run that support this include &lt;a href=&#34;https://miniflux.app/docs/configuration.html#auth-proxy-header&#34;&gt;Miniflux&lt;/a&gt;
(an RSS reader), &lt;a href=&#34;https://bayang.github.io/jelu-web/configuration/#configuring-proxy-authentication&#34;&gt;Jelu&lt;/a&gt; (a book tracker),
&lt;a href=&#34;https://linkding.link/options/#ld_enable_auth_proxy&#34;&gt;Linkding&lt;/a&gt; (a bookmark manager),
and &lt;a href=&#34;https://manual.seafile.com/latest/config/remote_user/&#34;&gt;Seafile&lt;/a&gt; (a file storage system).
If you’re hosting something yourself, look out for “proxy auth” or “remote users”
in the documentation or configuration. It’s amazingly convenient to be able to access things
without entering credentials.&lt;/p&gt;
&lt;h3 id=&#34;acls-tags-and-mistakes&#34;&gt;ACLs, tags and mistakes&lt;/h3&gt;
&lt;p&gt;For a long time I didn’t bother with any kind of ACLs within my Tailscale
network. Every machine could access every other machine. Then one day a thought
hit me. The default Tailscale SSH config re-prompts you for user authentication,
but I’d disabled that by tweaking the config… That means anyone with access to
any of my machines (or who managed to get Tailscale credentials out of one of
my apps) would be able to SSH into anywhere else on my network. Even as root!
Compromising a small bit of my network would immediately lead to a complete
take-over. What’s the point in defence-in-depth if you roll out a red carpet
over the drawbridge for any attackers?&lt;/p&gt;
&lt;p&gt;The first and most obvious fix for this was to restrict root access over SSH.
I changed my Tailscale ACL to prompt for reauthentication for root access, but
still allow regular SSH unprompted:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ssh&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;    &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:member&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;    &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:self&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;users&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:nonroot&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;check&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;    &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:member&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;    &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:self&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;users&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;root&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  &lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This still didn’t sit right with me, though. Why should a random webserver be
able to (theoretically) SSH into my desktop computer unchallenged? Tailscale
allows you to do more advanced ACLs by applying tags to devices. I dutifully
created a bunch of tags, and went through and tagged every device: &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;,
&lt;code&gt;app&lt;/code&gt;, &lt;code&gt;container&lt;/code&gt;, and so on. This, it turns out, was a mistake. I could no
longer SSH to anywhere. If I’d read
&lt;a href=&#34;https://tailscale.com/kb/1068/tags&#34;&gt;the docs&lt;/a&gt; properly, I might have noticed
how they describe tags:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Tailscale tags are how you authenticate and identify non-user devices, such as
servers and ephemeral nodes. They serve two primary purposes: to provide an
identity to non-user devices and to let you manage access control policies based on purpose.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;By applying a nice &lt;code&gt;user&lt;/code&gt; tag to all of my devices (computer, phone, tablet and
so on), I’d effectively removed my own identity from them. They no longer
counted for the &lt;code&gt;autogroup:member&lt;/code&gt; source in the SSH config. Whoops. And it’s
not easy to fix either:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You cannot remove all tags from a device. A device with a tag-based identity
must have at least one tag.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I’d have to remove all the affected devices, then re-add them. At the time
I wanted to roll back &lt;em&gt;all&lt;/em&gt; the tags, which would mean reconnecting dozens of
nodes to the Tailnet. I did end up doing that, but I also took it as an
opportunity to switch to KeyCloak as I mentioned earlier. Setting everything
up again was a bit less painful when it wasn’t just correcting a dumb mistake.&lt;/p&gt;
&lt;p&gt;I now have a much more sensible tag setup: servers are tagged &lt;code&gt;server&lt;/code&gt;,
apps are tagged &lt;code&gt;app&lt;/code&gt;, and a few special apps are tagged &lt;code&gt;integration&lt;/code&gt;. I then
use Tailscale’s grants to limit access within the tailnet:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;grants&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-c1&#34;&gt;// Users can access everything
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:member&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-c1&#34;&gt;// Servers and integrations can access integrations and apps
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:app&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;      &lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;    &lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;  &lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This stops anyone but a user device from accessing servers, and stops plain
apps from accessing anything at all. So for example my Grafana instance is
tagged as an &lt;code&gt;app&lt;/code&gt;; it doesn’t connect out over Tailscale for anything, it only
exposes its interface over Tailscale. My home automation software is tagged as
an &lt;code&gt;integration&lt;/code&gt;, as it reaches out to other services over Tailscale (e.g., to
push metrics it receives from devices).&lt;/p&gt;
&lt;p&gt;You could go even further and limit which specific machines talk to which
others, but I think this tier system sets reasonable boundaries for my use
without getting too much in the way.&lt;/p&gt;
&lt;h3 id=&#34;even-more&#34;&gt;Even more&lt;/h3&gt;
&lt;p&gt;There are lots of other cool features offered by Tailscale that I’ve not
covered: exit nodes (allowing you to route normal Internet traffic over
Tailscale; very handy when you’re behind a router you don’t trust, or if
you want to appear as being in another country for totally legitimate reasons);
Tailscale lock (a system where existing nodes have to verify any new ones,
guarding against both unauthorised access and Tailscale themselves inserting
nodes into your network); file sharing, and more I’ve probably forgotten.&lt;/p&gt;
&lt;p&gt;As I mentioned before, an awful lot is available on the free personal plan.
It’s definitely worth checking out if you manage servers, or self-host anything.
I can’t imagine going back to managing servers and private apps without
Tailscale, and I’m excited to see what else they do going forward.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;If you’re not familiar with it, WireGuard is a modern VPN included in
the Linux kernel. It’s basically the only VPN that isn’t terrible. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;They have installation instructions and packaged versions of the client
for just about every device you can imagine: Windows, Mac, every major Linux
distribution, iOS, Android, etc, etc &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;They even have an apt repository specifically for raspbian builds, so it’s
super easy. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;Lots of people seem to treat “magic” as a good thing in software. I think
it’s the absolutely opposite. I want to know how something works, not for it to
be inscrutable! &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;This is entirely optional, of course, you can just let each device do
their own thing as well. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:6&#34;&gt;
&lt;p&gt;I don’t think this is likely, but it’s one of my biggest concerns about
using “social” logins. More-or-less all of these services can arbitrarily ban
you with no recourse; why would you trust them as keyholders to other things? &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:6&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
</feed>
