<?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 debugging-beyond-the-debugger, g15-ram-upgrade, infinite-avatars, tailscale-docker-gotcha, why-you-should-be-using-https but not android-espresso-spoon</title>
    <subtitle>Personal homepage of Chris Smith</subtitle>
    <link href="https://chameth.com/feeds/posts/like/debugging-beyond-the-debugger,g15-ram-upgrade,infinite-avatars,tailscale-docker-gotcha,why-you-should-be-using-https/unlike/android-espresso-spoon/" rel="self"/>
    <link href="https://chameth.com/"/>
    <icon>https://chameth.com/favicon.png</icon>
    <updated>2026-08-27T00:00:00Z</updated>
    <id>https://chameth.com/</id>
    <author>
        <name>Chris Smith</name>
    </author>
    <entry>
        <title>Building a family of single-user services</title>
        <link href="https://chameth.com/building-a-family-of-single-user-services/"/>
        <updated>2026-08-27T00:00:00Z</updated>
        <id>https://chameth.com/building-a-family-of-single-user-services/</id>
        <content xml:lang="en" type="html">&lt;p&gt;Last month I &lt;a href=&#34;https://chameth.com/tracking-pbs-without-the-dark-patterns/&#34;&gt;made and wrote about&lt;/a&gt; a personal service
to track my workouts and record personal bests. Since then, things have escalated. I now have eight different
services running in the same “family”:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Collegiate Room&lt;/td&gt;
&lt;td&gt;Simple weather overview for my current location&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;German Mall&lt;/td&gt;
&lt;td&gt;LLM broker for use by other services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gloss White&lt;/td&gt;
&lt;td&gt;Weight tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Olive Crosses&lt;/td&gt;
&lt;td&gt;Central service hub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pompei Band&lt;/td&gt;
&lt;td&gt;Workout and personal best tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed Carp&lt;/td&gt;
&lt;td&gt;Spaced repetition system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiger Island&lt;/td&gt;
&lt;td&gt;Reading history and library tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical Horse&lt;/td&gt;
&lt;td&gt;Location history and analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The names are all anagrams of something related to their function. Finding those anagrams brings me an unreasonable
amount of joy. Unscrambling them is an exercise left to the reader. On top of those eight, I currently have four other
service ideas sat in my todo list. It’s going to be a big family.&lt;/p&gt;
&lt;h3 id=&#34;a-postmortem-on-the-previous-hodge-podge&#34;&gt;A postmortem on the previous hodge-podge&lt;/h3&gt;
&lt;p&gt;Most of this functionality isn’t new to me. When I started out playing around with LLMs, I created a “personal data
aggregator” that just kept getting things bolted on. I &lt;a href=&#34;https://chameth.com/an-app-can-be-a-ready-meal/#what-ive-made&#34;&gt;said at the time&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;My biggest just-for-me project is a web app that I started to help me aggregate film recommendations. It’s since morphed into a general personal data aggregation service: it deals with data from GitHub, Todoist, Letterboxd, TMDB, Healthkit, and others. It also lets me make re-orderable lists, store recipes, and more. Parts of this could definitely be open sourced, and I might carve them out at some point, but it’s mostly a glorious hodge-podge of things specific to me. Having all these services in one place lets me make quick and dirty automations, for example: when I create a Todoist note on my phone or watch, I often forget to set the due date, so it doesn’t show up in the “Today” view. It was literally a few lines of code to plumb things together so any inbox task without a due date gets set to today automatically.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In a twist that &lt;em&gt;nobody&lt;/em&gt; could see coming, it turns out that a “glorious hodge-podge” is actually quite hard to maintain
and very annoying to work with. It didn’t help that it had a fairly extensive Svelte frontend and I really am not a fan
of JavaScript. I ended up using it less and less just because it wasn’t fun to use or work on, and I didn’t want to
waste the limited LLM tokens on doing massive refactors or reworks.&lt;/p&gt;
&lt;p&gt;I’ve got almost two decades of professional software development experience under my belt, so you’d think I would
have learnt the lessons on how to write maintainable software. Hell, my CV even mentions that as something I’m
particularly good at. So what went wrong?&lt;/p&gt;
&lt;p&gt;There were a few factors. One of the obvious things to blame is that it was written mostly with LLMs.
Most of that blame isn’t very honest, though. In the year or so since, LLMs have definitely got better at writing
good code; but I was manually reviewing all the code at the time, and telling it quite carefully what to implement
and how to do it. So the buck really stops with me.&lt;/p&gt;
&lt;p&gt;I think it’s actually more because it was my &lt;em&gt;first&lt;/em&gt; larger project using LLMs. It’s a bit like learning a new
programming language. I don’t think the first sizable project I’ve written in any language has ever been any
good. You can read and learn as much as you want, but you need to actually run into some problems headfirst to
get a grip on how the language works. Or at least I do. LLM coding agents are basically the same deal.&lt;/p&gt;
&lt;p&gt;What the LLMs actually did was mask several problems. The architecture wasn’t right for how the service developed.
It started out as a single-purpose app and gradually got things bolted on, without any major changes. Everything
ended up in horizontal slices: a massive database layer with every query used by every part of the app, a massive
HTTP layer, and so on. Working on a single feature became a tap dance all over the source tree. This sort of
friction is normally quite apparent when you’re writing code by hand, and at some point it hurts enough that
you throw in the towel and do some refactoring. But the LLM will happily plod on without exposing that pain,
so the project needed a more active kind of architecture management that it just didn’t get.&lt;/p&gt;
&lt;p&gt;The other problem they covered up was how brittle and unmaintainable the JavaScript frontend was. I’m not
even sure how I ended up with a Svelte frontend in hindsight. It’s my preferred framework, but I still only
reach for it as a last resort when there’s no other way to avoid using one. It seems like there were lots of
ways to avoid it here, but I seem to have… well… avoided them. Anyway, each feature got its own slew of
Svelte components, without much thought to reusability, shared patterns, or anything sensible. Some of them
were just thin wrappers around data supplied by Go, others had vast swathes of logic in them, with no real
rhyme or reason as to which way that would fall. It’s the same issue as before: a human would look at it,
say “WTF?” and suggest tidying it up; instead, the LLM just gets on with what you tell it to do.&lt;/p&gt;
&lt;p&gt;There’s also a big non-LLM factor: one of the primary reasons all of this unrelated functionality got
tossed into the same app was that I’d previously worked on a few projects with a microservice architecture
and really didn’t like it. My conclusion was they only really worked in large environments where you can
dedicate people or teams to working on all the boilerplate that happens from maintaining lots of
interconnected projects. Dumping everything and the kitchen sink into one badly architected monolith was,
perhaps, not the best rebound from that.&lt;/p&gt;
&lt;h3 id=&#34;pompei-band-and-beyond&#34;&gt;Pompei Band and beyond&lt;/h3&gt;
&lt;p&gt;When I wanted to implement &lt;del&gt;Strava at home&lt;/del&gt; my PB tracking app, it seemed fairly natural to build it out
as a new service, rather than bolt it on to the slowly decomposing hodge-podge that was giving off a funny
smell. Then when I wanted a weather dashboard, why not do it in the same style as Pompei Band? The same
serving architecture, the same page layout… I could even come up with a similar weird two-word name!&lt;/p&gt;
&lt;p&gt;Then wouldn’t it be nice if the weather dashboard knew where I was, so it could show the local weather?
I could just build that functionality in, but it’d also be a good opportunity to carve out the location
history feature from my old service and make a new, shinier one. Then basically every time I even thought
about going near the old service, I mentally sketched out a replacement service.&lt;/p&gt;
&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.png&#34; alt=&#34;A screenshot of Olive Crosses, showing links to other services with icons based on their two-word names&#34; loading=&#34;lazy&#34; width=&#34;1230&#34; height=&#34;619&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Olive Crosses showing links to the other services&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;At one point I had the thought that it’d be nice if the services could all link to one another, but I
didn’t want to have to touch every service whenever I added a new one. So I made a “hub” service, which
maintains the One True Catalogue, and provides a bit of JavaScript the others can all embed to add a
navigation sidebar.&lt;/p&gt;
&lt;p&gt;So after complaining about microservices and JavaScript, it sounds like I have landed on microservices
(or at least miniservices) and JavaScript? Sort of yes, but sort of no. Each service is still its own
thing and makes sense independent of the others. There are some cross-service dependencies, but they’re
very simple and don’t have the same cognitive or boilerplate overhead as a login service that talks to
an authentication service that queries a user manager and so on. And as for the JavaScript, it’s almost
exclusively used for progressive enhancement and a few limited client-side visual things like
maps and graphs. All of the logic lives in Go, which is a lot easier to maintain and test, and all of
the pages are simple Go templates.&lt;/p&gt;
&lt;p&gt;Having these services built in a way that I actually like has motivated me to play around a lot more
with new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;My workout tracking app pulls photos from my private &lt;a href=&#34;https://immich.app&#34;&gt;Immich&lt;/a&gt; server that were
taken during workouts, and shows them alongside. Now I can see all my obligatory end-of-run selfies
in one place.&lt;/li&gt;
&lt;li&gt;The location history app can detect trips away from home, and get an LLM to automatically title them.
My most recent, very exotic, trip has the title “A fortnight in Milton Keynes and Coventry”. Each
trip gets its own little map, and in the future I want to pull in images like with workouts.&lt;/li&gt;
&lt;li&gt;My weight tracking app now exports weigh-ins to Garmin, as it doesn’t fully integrate with Apple
Health (it will export its own weights, but not import others, like the ones from my Withings scales).
Stupid walled gardens, but thankfully I can fix it myself.&lt;/li&gt;
&lt;li&gt;My book tracking app automatically cleans up the titles of books from the nonsense provided by Amazon
to something more palatable. “Killing Eve: Long Shot: A twist on obsession, betrayal and revenge from
Luke Jennings, author of the TV sensation Killing Eve” becomes “Long Shot (Killing Eve #5)”. It does
this by passing the title to an LLM with a bunch of rules.&lt;/li&gt;
&lt;li&gt;The hub generates me a snippet of Tailscale configuration that defines the minimal grants needed to
allow the intra-service dependencies to work.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The LLM integrations were inspired by my friend &lt;a href=&#34;https://dataforce.org.uk/&#34;&gt;Shane&lt;/a&gt; who recently
demonstrated an inventory tracking app he’d made, which used Haiku to process images of boxes and
describe the contents. I’ve dabbled with LLM integration before, and always disliked the process
of fiddling with the prompts, and debugging when the LLM gave a nonsensical response. So I built
German Mall, which makes it a lot easier. It lets me define a “workflow” consisting of a system
prompt, initial message, and expected response format. I can browse past runs, tweak the prompt,
rerun them to see the change, all without touching the services using them. Quick iteration loops
are the best. German Mall also provides markdown documentation for each workflow, so integrating
it downstream is simply a case of pointing a coding agent at the right documentation endpoint.&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.png&#34; alt=&#34;A screenshot of German Mall, showing the workflow configuration for tidying book titles: a form with fields for description, system prompt, user template, output schema, model, max tokens and temperature.&#34; loading=&#34;lazy&#34; width=&#34;1482&#34; height=&#34;1080&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;The “tidy book titles” workflow in German Mall&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;It’s not all fun and games: having a bunch of services all following the same pattern means
you need to do cross-cutting maintenance on them now and then. But that’s surprisingly easy with LLMs.
I put all the projects in a common folder, and I launch cross-cutting tasks from that folder. Usually
I’ll make the change in one project, and then the cross-cutting task is simply “Look at commit XYZ in
./foo, make the same change to the other projects”.&lt;/p&gt;
&lt;p&gt;I’ve also ended up with a shared library used by all the projects to cut down on some boilerplate.
That’s normally something I’d resist, especially with personal projects, just because the extra overhead
of working in a separate project, managing releases of it, and so on, is a bit annoying. But, again,
LLMs can do almost all of that grunt work. They effectively reduce the “cost” of maintaining separate
services to a negligible level, leaving me to focus on the more fun aspects like coming up with new names.&lt;/p&gt;
</content>
    </entry>
    <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>Exposing game servers over Tailscale</title>
        <link href="https://chameth.com/exposing-game-servers-over-tailscale/"/>
        <updated>2025-12-07T00:00:00Z</updated>
        <id>https://chameth.com/exposing-game-servers-over-tailscale/</id>
        <content xml:lang="en" type="html">&lt;p&gt;I’ve recently been playing a lot of &lt;a href=&#34;https://factorio.com/&#34;&gt;Factorio&lt;/a&gt; with a friend. I’ve been
hosting, but my desktop computer is behind far too many layers of NAT, and I can’t be bothered
dealing with setting up port forwards. Up until today we made do with Steam’s networking support,
which in our case ended up relaying the connection via one of their servers. This is amazing as
a free, no-hassle service, but the performance was so-so. We’d get random lag spikes, or the
initial map download would crawl along at about 1/100th of the speed we should be able to get.
We’re both fans of &lt;a href=&#34;https://tailscale.com/&#34;&gt;Tailscale&lt;/a&gt; though, so maybe there’s an easy solution
there?&lt;/p&gt;
&lt;p&gt;Tailscale even have an article on &lt;a href=&#34;https://tailscale.com/blog/factorio-multiplayer-video&#34;&gt;sharing a Factorio server&lt;/a&gt;
but it’s a ten minute long video&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;, and focused on setting up an actual dedicated server in Docker
rather than just clicking the convenient “Host” button in the game client. What we did instead was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I shared my desktop’s Tailscale node with my friend, by creating a sharing link in the &lt;a href=&#34;https://login.tailscale.com/admin/machines&#34;&gt;Admin console&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;My friend accepted the link, and could then see my machine in their admin panel&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;tailscale lock&lt;/code&gt; to sign my friend’s desktop’s node key, as I have &lt;a href=&#34;https://tailscale.com/kb/1226/tailnet-lock&#34;&gt;tailnet lock&lt;/a&gt; enabled&lt;/li&gt;
&lt;li&gt;I added a grant to my tailnet’s ACL to permit access from my friend to the specific IP/port on my tailnet:&lt;/li&gt;
&lt;/ol&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;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;friend@example.com&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;100.0.0.42&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;fd7a::42&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;udp:34197&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;/code&gt;&lt;/pre&gt;&lt;p&gt;After this, my friend could connect to Factorio by entering the Tailscale IP address, or the Tailscale hostname (as they use &lt;a href=&#34;https://tailscale.com/kb/1081/magicdns&#34;&gt;MagicDNS&lt;/a&gt;).
Running &lt;code&gt;tailscale status&lt;/code&gt; shows that Tailscale managed to establish a direct connection despite the many layers of NAT involved,
and the performance improvements were significant and immediate.&lt;/p&gt;
&lt;p&gt;We did a bit of testing, and confirmed that no other services on my machine are accessible. And sharing a machine by default
only allows it to accept incoming connections, so I can’t get unwanted access to anything on my friend’s Tailnet, either.&lt;/p&gt;
&lt;p&gt;I’ve &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;written before&lt;/a&gt; about all the different ways I use Tailscale; this is yet
another new way it’s solved a problem for me.&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’m not a fan of video tutorials at the best of times, but especially not when I want to do something as quickly as possible so I can get back to &lt;del&gt;my addiction&lt;/del&gt; growing the factory. &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;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>Avoiding the Consequences of Dumb Laws with Tailscale</title>
        <link href="https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/"/>
        <updated>2025-09-30T00:00:00Z</updated>
        <id>https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/</id>
        <content xml:lang="en" type="html">&lt;p&gt;More and more sites are implementing privacy-invading age checks or just
completely blocking the UK thanks to the &lt;a href=&#34;https://www.legislation.gov.uk/ukpga/2023/50/contents&#34;&gt;Online Safety Act&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Protecting kids from &lt;em&gt;some&lt;/em&gt; content online is certainly a noble goal, but
the asinine guidance from Ofcom, threats of absolutely disproportionate fines,
and the stupidly broad categories of content have resulted in companies just
giving up or going through a tick-box exercise that offers very little
protection but lots of inconvenience and a complete invasion of privacy.&lt;/p&gt;
&lt;p&gt;Instead of uploading my ID to some third party company, I’ve taken to proxying
my traffic through to a country that doesn’t have such stupid laws. Thankfully,
Tailscale makes this really easy. I’ve discussed &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;how I use Tailscale&lt;/a&gt;
before, but not really covered &lt;em&gt;app connectors&lt;/em&gt;. I find Tailscale’s description
of these pretty confusing, but they basically amount to automatic, DNS-based
subnet routing configurations (or, to put it another way, a per-website exit
node). You can safely ignore all references to ‘SaaS apps’ in their docs.&lt;/p&gt;
&lt;p&gt;I create a custom app connector, and give it the domains to be included:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.png&#34; alt=&#34;Screenshot of the app section in the Tailscale admin console. It shows a table with two entries: &amp;#39;reddit&amp;#39; and &amp;#39;bluesky&amp;#39;. Each entry has a list of domain names like &amp;#39;*.reddit.com, *.reddit.it&amp;#39;.&#34; loading=&#34;lazy&#34; width=&#34;1151&#34; height=&#34;397&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;App configuration in the Tailscale admin console&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Tailscale then magically resolves those domains, and has the ‘connector’
advertise routes for them. Any client that accepts routes will start sending
requests to the connector, which passes them onto the Internet at large. Any
other traffic is left alone, unlike when you use an exit node.&lt;/p&gt;
&lt;p&gt;The special bit here is how you can specify wildcard domains. Tailscale proxies
the DNS requests from clients (so it can inject responses for nodes on your
tailnet), which means it can dynamically update the routes as you resolve new
domains. I tried to set this up more manually, and quickly came unstuck: despite
using the same DNS servers, my server and my desktop would get different responses
for the same query as it varied by geography. Trying to get the full set of
IPs (and keeping them updated) would have been a nightmare. Tailscale expanding
the wildcards nicely sidesteps all of that.&lt;/p&gt;
&lt;p&gt;At first I was just proxying the traffic to one of my servers, but just today
I added a new connector for Imgur and found I was still blocked, just for
different reasons. They not only block my entire country but also a load
of known datacenter IP ranges. Hmph. I fixed this by hacking up a new side
project: &lt;a href=&#34;https://github.com/csmith/tsv&#34;&gt;tsv&lt;/a&gt;. It’s a simple Go app that accepts
traffic from the tailnet (advertising itself as both an app connector and an
exit node), and passes it on to another VPN.&lt;/p&gt;
&lt;p&gt;There are lots of other ways you could accomplish this, but this makes it so
all my devices can still access services without any additional configuration.
As long as Tailscale is installed, the Internet will still work as it’s meant
to, without all the nonsense. If I come across a site that doesn’t work, adding
it is trivial: I just make a new app connector in Tailscale.&lt;/p&gt;
&lt;p&gt;Obvious disclaimer: the laws in the UK are binding on the service providers,
not the end user. Doing this sort of thing in other countries might be illegal.
I don’t know; do your own research! Also all of this is a workaround
for something that should be fixed at a legislative level, but I’m not holding
my breath.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Making a font of my handwriting</title>
        <link href="https://chameth.com/making-a-font-of-my-handwriting/"/>
        <updated>2025-08-08T00:00:00Z</updated>
        <id>https://chameth.com/making-a-font-of-my-handwriting/</id>
        <content xml:lang="en" type="html">&lt;p&gt;Recently I’ve been on a small campaign to try to make my personal website
more… personal. Little ways to make it obvious it’s &lt;em&gt;mine&lt;/em&gt; and &lt;em&gt;personal&lt;/em&gt;,
not just another piece of the boring corporate dystopia that is most of
the web these days. I don’t quite want to fully regress to the Geocities era
and fill the screen with animated under construction GIFs, but I do want to
capture some of that vibe.&lt;/p&gt;
&lt;p&gt;I’d added some bits and pieces along those lines: floating images in articles
now look like they’re stuck to the page with sellotape, related post
links have a wavy border that animates when you hover over them, and so on.
Next, I wanted to change the heading fonts from a monospace font to something
cursive, to resemble handwriting. Less terminal output, more handwritten letter.
I couldn’t find one I liked, though. So why not make my own? It can’t be that
hard, right?&lt;/p&gt;
&lt;h3 id=&#34;failing-to-do-it-myself&#34;&gt;Failing to do it myself&lt;/h3&gt;
&lt;p&gt;I set out to try to make the font myself using open source tools. After doing
a bit of research, it seemed like the general approach was to create vectors of
each character and then import them into a font editor. That seems to mean
either Adobe Illustrator and FontLab (if you have too much money) or Inkscape
and FontForge (if you like open source). I fall firmly into the latter category,
so I grabbed my graphics tablet and opened Inkscape.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;I wrote out my first three letters: capital A, B and C. Saved them in Inkscape,
and attempted to import them into FontForge. Then I remembered one crucial
thing that had slipped my mind: I absolutely loathe using FontForge. It’s a bit
like when you open an old version of GIMP and get a bunch of weird looking
windows floating all over the place; it feels like you’re fighting against the
tool to do even the most basic operations. The difference is I have cause to
edit images a &lt;em&gt;lot&lt;/em&gt; more than I edit fonts, and GIMP has actually significantly
improved their UI over the years.&lt;/p&gt;
&lt;p&gt;Here are the rough steps I went through with FontForge:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Launch Font Forge. It shows a weird bit of art in one window, and an open
file dialog in another.&lt;/li&gt;
&lt;li&gt;I don’t want to open a file, so I close that dialog. The program exits.&lt;/li&gt;
&lt;li&gt;Relaunch Font Forge, and realise that within the “Open Font” dialog is a
“New” button. Click it.&lt;/li&gt;
&lt;li&gt;Get to the standard font-editing UI. Right-click on the “A” looking for
a way to import an SVG. Don’t see one.&lt;/li&gt;
&lt;li&gt;Click around a bit, exploring the menus. Everything feels a bit off.
You can’t open one menu then hover over the next to see its content, like
basically every UI toolkit in existence. I think FontForge has eschewed QT
and GTK in favour of doing things itself.&lt;/li&gt;
&lt;li&gt;Find the “Import” option in the File menu. Hope it’s for a single glyph not
the whole font.&lt;/li&gt;
&lt;li&gt;A file picker opens. Again it’s all a bit off from normal desktop
conventions. Try to resize it, and just get blank grey space at the bottom.&lt;/li&gt;
&lt;li&gt;Type the absolute path I want to go to in the text field.&lt;/li&gt;
&lt;li&gt;Get a dialog saying “Not a bdf file /home/chris/etc”. Press OK.&lt;/li&gt;
&lt;li&gt;Get a dialog saying “Could not find a bitmap font in”. Press OK.&lt;/li&gt;
&lt;li&gt;Press Ctrl+L to see if that lets me enter a path. Click everything in the
dialog to try to find a way to enter a path. Get annoyed. Give up. Click
through folder-by-folder to get to where I want to be.&lt;/li&gt;
&lt;li&gt;Get to the folder and don’t see any files. Change the format to “SVG”.
Double-click the newly-visible SVG file.&lt;/li&gt;
&lt;li&gt;Get a dialog saying “You must select a glyph before you can import an image
into it”. Press OK.&lt;/li&gt;
&lt;li&gt;The import dialog goes away, having not imported.&lt;/li&gt;
&lt;li&gt;Select the glyph in the main tool area, then repeat the File→Import dance.&lt;/li&gt;
&lt;li&gt;It’s actually there now! Open the glyph in the editor and see it’s
a complete mess of Bézier curves. I can’t click what I want without
accidentally moving a handle for an adjacent curve.&lt;/li&gt;
&lt;li&gt;Rage-quit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I’m sure FontForge is less anger inducing once you’re used to it. And you
definitely could use it to build a font like this if you had much more patience
than me. I’d had enough of death-by-a-thousand-paper-cuts though.&lt;/p&gt;
&lt;p&gt;I briefly tried Inkscape’s built-in support for making an SVG font. It annoyed
me a lot less, but it’s fiddly: it seemed like each glyph had to be a single
path, so you had to convert the glyphs to paths, then merge them correctly.
If you merge them incorrectly then the wrong bits of your letters end up
filled (like the inside of the ‘B’). Path manipulation is getting towards the
limit of my knowledge of vector editing, and it took a bit of trial and error
for each letter that had more than a single stroke. I didn’t fancy doing that
for every letter.&lt;/p&gt;
&lt;p&gt;I’m usually a big advocate of open source, but this was one of those painful
times when it feels like it just falls short. Clunky, painful UI and processes
where commercial tools just let you get on with your work.&lt;/p&gt;
&lt;h3 id=&#34;you-can-exchange-money-for-goods-and-services&#34;&gt;You can exchange money for goods and services&lt;/h3&gt;
&lt;p&gt;When I’d been looking for open source tutorials, I found many mentions of
a closed source, hosted tool: &lt;a href=&#34;https://www.calligraphr.com/en/&#34;&gt;Calligraphr&lt;/a&gt;.
It has a free version with limitations (no ligatures, no variations, 75
glyphs per font), and a pro version for £8/month. I’d normally balk at the
idea of a subscription for this, but they have the perfect answer: you can
make a one-time payment, and your account automatically downgrades back to free
after a month. It’s not a hidden option, either, it’s the most prominent button
on the upgrade page. That made me happy to give them £8 to play around with
the service for a month.&lt;/p&gt;
&lt;p&gt;Calligraphr works by having you print templates, write out the letters, then
scan them in. It does some magical processing to extract the glyphs, provides
tools to tidy them up, align them, etc, and then produces a TTF file for you.
You can see some of my completed templates here:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/making-a-font-of-my-handwriting/template.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/making-a-font-of-my-handwriting/template.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/making-a-font-of-my-handwriting/template.jpg&#34; alt=&#34;Eight scanned template sheets, filled in with handwritten letters and ligatures&#34; loading=&#34;lazy&#34; width=&#34;1166&#34; height=&#34;841&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Most of the templates I used for the font&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Calligraphr has a nice UI to generate the templates, allowing you to select
which glyphs to include. I added the “minimal English”, “basic punctuation”
and “Ligatures” sets. That gave me four pages to fill out, and I did them all
twice. That let me filter out versions that didn’t work well, and have
variants for some letters so the font wasn’t too repetitive. Later on, I went
back and added some custom ligatures based on blog post titles that didn’t look
quite right: “Re”, “To”, “ers”, “ey”, “hy”, “ra”, “re” and “ty”. Ligatures like
this help it look more natural: when we write we don’t just stamp out identical
letters regardless of their surroundings, instead they will connect to their
neighbours, or overlap slightly, or even share a stroke.&lt;/p&gt;
&lt;p&gt;I filled these templates in with a Sharpie, as I wanted a fairly informal,
scrap-booky look, and it would also give good solid shapes that should be easy
to pick out of the template. I scanned them with the “Scan Document” function
on my iPhone, and uploaded the PDFs to Calligraphr.&lt;/p&gt;
&lt;h3 id=&#34;iterating-and-tweaking&#34;&gt;Iterating and tweaking&lt;/h3&gt;
&lt;p&gt;The Calligraphr UI allows you to preview the font, but I found it a lot more
useful to just download a copy and use it on a local copy of my website.
That let me test it with real text, and see how it’d look at the different font
sizes I use on the site.&lt;/p&gt;
&lt;p&gt;The first version was not great. Despite the guidelines on the template, I
apparently wasn’t good at sticking to them. Some letters were floating way off
the baseline, and some were sunken below. When those opposites met it looked
terrible. Fortunately Calligraphr has a pretty easy tool to slide each letter up
and down, and scale it up or down if needed, and you can see it next to other
letters as you do it. It took a little bit of time to go through all the
variants of all the letters, but the next version looked a lot better.&lt;/p&gt;
&lt;p&gt;Another tweak I ended up doing was reducing the spacing between letters. The
defaults Calligraphr uses are probably good for a blocky font, but I wanted to
put the letters close together to give it more of a joined-up look.
Again, this is an easy tool to use, you just drag the sides in or out as
desired. While these tweaking steps were probably as fiddly as some of the
Inkscape steps I refused to do earlier, they’re a lot more rewarding as you
see things improving with each one. It’s a lot easier for me to commit time
and effort to improving something that’s already working reasonably, than put
that time and energy into an unknown.&lt;/p&gt;
&lt;p&gt;Later, I noticed that occasionally there would be a huge gap in a title. Not
“the kerning is slightly off” but “there’s enough room to park a bus”. It took
me a while to figure out what was happening: a couple of glyphs
hadn’t been isolated perfectly and had picked up a few pixels from the template
lines at the edge of their boxes. That meant the glyph had a width that covered
the actual written glyph, a big gap, and then the rogue marks. At first, I fixed
this by just adjusting the width, but that left the little pixels floating
awkwardly down-sentence. The proper fix was to use the editing tool and simply
delete them, and then Calligraphr snapped the width back to what it should be.&lt;/p&gt;
&lt;p&gt;These iterations took a while to do, but I just dipped in and out occasionally
over the course of a week, so it didn’t actually feel like too much work. I
quite enjoy the process of refining things, too.&lt;/p&gt;
&lt;h3 id=&#34;result-and-a-surprise&#34;&gt;Result and a surprise&lt;/h3&gt;
&lt;p&gt;If you’re viewing this post on my website&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;, you can see the font in the
headers, captions, and a few other places. Here’s how it compares to my actual
handwriting:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/making-a-font-of-my-handwriting/sample.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/making-a-font-of-my-handwriting/sample.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/making-a-font-of-my-handwriting/sample.jpg&#34; alt=&#34;A hand-written line of text saying &amp;#39;Hello World! This is Chris Hand&amp;#39; above the same line of text in the Chris Hand font&#34; loading=&#34;lazy&#34; width=&#34;763&#34; height=&#34;194&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;My handwriting vs my handwriting font&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;It’s not close enough to forge documents, but I think it definitely gets
across my style, and that’s exactly what I wanted. It’s surprisingly legible
even at smaller font sizes — I think the weight of the Sharpie helps here — and
at £8 and a bit of manual work was a lot more economical than spending days
wresting with open source tools.&lt;/p&gt;
&lt;p&gt;A few weeks after I put the finishing touches on the font, I got an e-mail
from Calligraphr. As my account had lapsed back to the free version, I was
no longer eligible for the “server-side backup” feature. So what did they do?
They e-mailed me an exported copy! It’s a JSON file with the properties of each
glyph and a base64 encoded image. Not only can I re-upload this to Calligraphr
if I resubscribe, I can probably hook something up to edit it should I ever
need to. I’m blown away by how pro-user Calligraphr’s business practices are.
They’re up-front about pricing, don’t try to get you stuck on an auto-renewing
subscription, and automatically export your data. It’s like a breath of fresh
air compared to the barrage of dark patterns that other websites foist on us.
If you want to make this kind of font, I’d definitely recommend them just
because of how &lt;em&gt;nice&lt;/em&gt; they are.&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;And I haven’t changed everything since writing this post… &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;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
</feed>
