30 May 2026

📌 Saturday night in Gourock, the Firth black under a sky that never quite committed to rain all day, The Protomen's Act III: This City Made Us loud in the headphones - a record twenty years in the making, the third act of a rock opera about robots refusing to accept that nothing can be changed. I spent the evening replacing my homepage. Not a glamorous job. But somewhere around the third broken certificate I realised I was building, one more time, a small machine whose only purpose is to stand between me and the platforms.

the thing I was replacing

For a long time the first tab in every browser I own opened on Heimdall - a wall of icons, each a link to something in the rack. It does one thing and does it cleanly: you click a tile, you go to a service. But it's a launcher. It doesn't know anything. It can't tell me whether the service behind the tile is actually up, or what the last article in my feed reader was, or what's playing on the stereo in the other room. It's a bookmarks page wearing a nice theme.

Glance is a different animal. It's a dashboard that pulls live state from everywhere I point it and lays it out in three columns of YAML. The default config it ships with is a tour of everything I've spent years getting off my machines - a YouTube widget, two Reddit feeds, a row of Twitch channels, stock tickers. The irony of a self-hoster's dashboard defaulting to the exact surveillance-web furniture I run front-ends to avoid wasn't lost on me. So the evening became an exercise in tearing all of that out and wiring in the things that are actually mine.

the front-ends, again

The shape that emerged is familiar. Where the default wanted YouTube, I pointed the video widget at my own Invidious instance. Where it wanted an external RSS aggregator, I pulled the feed straight out of my FreshRSS. The news columns - Hacker News, Lobsters - stayed, because those are open and don't need a middleman. Everything else routes through something I run.

The video widget is the honest one about the limits of all this. It renders nicely, links every video back to my Invidious so the click never touches Google - but the discovery of new videos still hits YouTube's API to ask which videos a channel has. The same Invidious whose channel pages I had to rebuild from a contributor's draft branch six days ago, because somebody in Mountain View renamed a JSON key. I am, as I wrote then, a guest on someone else's web, and the host moves the furniture whenever it likes. The dashboard widget is just one more place where that guest status shows through. I left it for now. The fully-private version means feeding it Invidious channel feeds by hand, and that's a job for a quieter night.

the part that fought back

FreshRSS should have been the easy one. Point the widget at a feed URL, done. Instead it locked me out of my own reader.

The first sign was a 403 where the login form should have been, with the telling detail in the error - Remote-User= empty, a request coming from 172.18.0.3, a Docker-internal address. The reader was configured for HTTP header auth, the mode where a reverse proxy is supposed to inject the authenticated username as a header. Nginx Proxy Manager wasn't injecting it, so FreshRSS saw an empty user and slammed the door. The fix was one line in config.php:

'auth_type' => 'form',

http_auth out, form in, restart, and the login page came back. That auth_type had been sitting wrong for who knows how long - it only mattered the moment I tried to reach the thing programmatically.

Then the certificate. With the reader reachable again, the widget still showed failed to retrieve any content. The log was specific in the way only TLS errors are specific:

tls: failed to verify certificate: x509: certificate has expired
  or is not yet valid: current time 2026-05-30T17:38:17Z
  is after 2026-03-29T17:45:59Z

The self-signed cert on the reader's internal nginx had expired on the 29th of March. Two months dead, and I'd never noticed, because I always reach FreshRSS through NPM with its valid Let's Encrypt cert - the broken internal cert only surfaced when one container tried to talk to another over the internal address. The fix was to stop being clever about internal IPs and let the widget go through the front door like everything else: the public hostname, the real cert, no exceptions.

There's a small lesson filed there next to the others. Closed but not patched, fixed but not merged, and now this one - working but not verified. A thing can be doing its job perfectly from where you usually stand and be quietly broken from every other angle. The expired cert protected nothing and broke nothing for two months because nobody ever approached it from the side where it was load-bearing. I renewed it through NPM afterwards, so the internal door has a valid cert again - but that was tidying up, not the fix. The fix was routing through the front door I should have been using all along.

the part I'm pleased with

The piece that took the most work is the one I care about most, and it's the stereo.

Navidrome - my music server, three thousand-odd FLACs and OGGs ripped from CDs I actually own - speaks the Subsonic API. Glance has no Navidrome widget, but it has a custom-api widget that will hit any JSON endpoint and render the result through a Go template. So I wrote one.

The Subsonic auth is its own little ritual. No bearer token, no OAuth - you generate a random salt, hash your password against it with md5(password + salt), and send the salt and the hash as query parameters with every request. The token and salt are a matched pair, born together, useless apart. Once getNowPlaying answered "status":"ok" the rest was templating - pulling title, artist, album, genre, the duration in seconds turned into m:ss with a bit of integer division, and the album cover fetched from getCoverArt. The credentials live in a .env file with 600 permissions, fed to the container as environment variables, never written into the config itself.

The whole widget is one block of YAML. The ${...} are the env vars from the .env - the only sensitive parts, kept out of the file:

- type: custom-api
  title: Navidrome — Now Playing
  cache: 20s
  url: https://navidrome.jolek78.dev/rest/getNowPlaying?u=${NAVIDROME_USER}&t=${NAVIDROME_TOKEN}&s=${NAVIDROME_SALT}&v=1.16.1&c=glance&f=json
  template: |
    {{ $entries := .JSON.Array "subsonic-response.nowPlaying.entry" }}
    {{ if eq (len $entries) 0 }}
      <p class="color-subdue">Nothing playing</p>
    {{ else }}
      <ul class="list list-gap-14">
      {{ range $entries }}
        <li class="flex gap-15 items-center">
          <img src="https://navidrome.jolek78.dev/rest/getCoverArt?id={{ .String "coverArt" }}&size=120&u=${NAVIDROME_USER}&t=${NAVIDROME_TOKEN}&s=${NAVIDROME_SALT}&v=1.16.1&c=glance"
               width="64" height="64" style="border-radius:6px;flex-shrink:0;" loading="lazy">
          <div class="flex flex-column min-width-0">
            <a class="size-h3 color-highlight block text-truncate"
               href="https://navidrome.jolek78.dev/app/#/album/{{ .String "albumId" }}/show" target="_blank">{{ .String "title" }}</a>
            <ul class="list-horizontal-text">
              <li class="color-primary">{{ .String "artist" }}</li>
              <li>{{ .String "album" }}</li>
              <li>{{ .String "genre" }}</li>
            </ul>
            <ul class="list-horizontal-text size-h6 color-subdue">
              <li>{{ div (.Int "duration") 60 }}:{{ printf "%02d" (mod (.Int "duration") 60) }}</li>
              <li>{{ .String "playerName" }}</li>
              {{ if eq (.Int "minutesAgo") 0 }}<li>now</li>{{ else }}<li>{{ .Int "minutesAgo" }} min ago</li>{{ end }}
            </ul>
          </div>
        </li>
      {{ end }}
      </ul>
    {{ end }}

The {{ if eq (len $entries) 0 }} branch matters more than it looks - getNowPlaying returns nothing when nothing is playing, and without that check the widget renders as broken rather than as Nothing playing. The cache: 20s keeps it close to live without hammering the server.

What it gives me is a small panel in the centre column that shows, in real time, whatever I'm playing - cover art, track, artist, the album it's from. Right now it's reading This City Made Us, The Protomen, six minutes and three seconds, NavidromeUI, now.

And that's the whole point of the thing, sitting there in one widget. Not a recommendation engine guessing what I might tolerate next. Not a stream I rent until the licensing deal lapses and the album greys out. A panel reading the state of a server in my own rack, playing a file I own, off a disk I can hold. The cover art is fetched from my machine. The metadata was written by me when I ripped the disc. Nobody is watching what I listen to, because there's no they in the loop at all.

SolarPunk, again, applied

I keep ending up in the same place. Two weeks ago it was a 2014 watch brought back with an eight-quid battery and a community OS, and I called it SolarPunk applied - not a garden on a skyscraper, just a dead device refusing to stay dead. Tonight it's quieter than that. Nothing was resurrected. Nothing heroic happened. I spent an evening moving my own front door so that the first thing I see when I open a browser is a picture of my infrastructure instead of someone else's feed.

But it's the same instinct. The Protomen spent twenty years finishing a story about a city that's been told, over and over, that the way things are is the way things have to be - that you can't fight it, that resistance is naive, that you should keep your head down and accept the platform you were given. The whole record is an argument against that fatalism, scored for distorted guitars and a saxophone. It is, I'll admit, a slightly absurd thing to have playing while editing YAML. But the through-line is real. Every one of these evenings is a small refusal of the same idea: that the device is an appliance, that the service is a tenancy, that the web is a place you visit on someone else's terms.

The dashboard is three columns of refusal. Infrastructure I can see. Feeds I host. Videos that don't phone home, mostly. And a music player that is just my own files, played back to me, with nobody keeping score.


The track changed while I was writing this. Light's Last Stand now, building toward the end of the act. The cat that was on the chair has relocated to the windowsill, watching nothing move on the dark water. The cert is fixed, the widgets are green, the music is mine, and that's a good enough Saturday.