diary @ telent

Secret dogfood#

Sun 13 Sep 2026 06:11:23 PM UTC

Topics: liminix

Having admitted the other day that I could have written about

rigging up the Liminix wireless extender in the shed to use SOPS for its secrets (unwritten because I ... well, mostly because I just haven't written it)

but hadn't, I felt compelled to sort it out.

I've been working on this on and off for longer than I can remember, because the whole thing still feels brittle and hard to test, and is for that reason unsatisfying. But the other device providing wifi in the shed seems to have entirely ceased working, and it's where I perform $dayjob when I'm not in the office, so I needed to do something as it's embarrassing to have my Zoom calls drop out.

(Every other important device in the shed has wired ethernet, but my work Mac is too locked down and corporate to make that an option)

There are a couple of things I want the new generation of Liminix devices to do that the old ones don't:

As the first of them was "basically finished", I have been spending hours/months (actual/elapsed) on the second.

In essence it's simple: Liminix already has code to get secrets from a server, which is basically anything that can serve a JSON file over HTTP(S). A service on the client gets the JSON file at configurable intervals, and unpacks it into a directory under /run/services/outputs/service-name where it can be referenced by any other service that needs it

In essence it would be simple, so of course I have accidentally it more complicated, by deciding to keep secrets in SOPS and to use HTTPS client certificates for authentication.

The threat modelling here might be a little weird, to be honest. using SOPS means that our secrets are encrypted at rest and we can back them up, put them in git servers etc with impunity, etc but instead of having a decryption key per device we decrypt using a single key owned by the https server, and then re-encrypt (courtesy of TLS) for the wire. The more sympathetic way to use SOPS would probably be to use ssh host keys as age identities, but that requires getting the host keys off each of the devices after they've been created at first boot, then re-encrypting the file for all the recipients.

How the client gets its certificate in the first place is the part that's still a bit unsatisfying, because it's automated and the authentication uses a shared secret that's baked into the Liminix image - so anyone with a copy of one of your devices' Liminix images and binwalk could make themselves a TLS cert and fire off a request to your Certifix service to sign it. But when the machine seeking a signature has no identifying features that can't be faked, and no i/o capabilities, how does it attest that it really is the machine it claims to be, and not an imposter?

Anyway, I've done none of those things, even the ones that are possible. But it's up and running and I flashed it using Levitate using a chunk of code like this in its configuration


  defaultProfile.packages = with pkgs; [
    (levitate.override {
      config = {
        imports = [ "${modulesPath}/ssh" ];
        programs.busybox.applets = [ "flashcp" ];
        services = {
          # dhcp on wired ethernet only instead of trying to bring up
          # all the wifi stuff
          dhcpc4 =  svc.dhcp4c.client.build {
            interface = config.hardware.networkInterfaces.lan;
            dependencies = [ config.services.hostname ];
          };
          # for recovery image we will just embed static keys instead of
          # running the full secrets management doodad
          sshd = svc.ssh.build {
            authorizedKeys.root = import ../users/dan/authorized-keys.nix;
          };
        };
      };
    })
  ];

There is a gotcha here that I need to address: you have to re-import the ssh module inside levitate's config, otherwise although it finds svc.ssh OK, it doesn't set config.programs.busybox.options.FEATURE_FANCY_ECHO and as a result the authorizedKeys file is garbage.

Anyway, it's up and running and doesn't seem to have crashed yet. There is something weird (but, I think, basically cosmetic) going on with the log shipping forwarder which keeps stopping and restarting whenever it does a DHCP renew, but I will write about that another time - hopefully, after I have found out what the hell is happening.

In other news, Liminix is currently not building against nixpkgs unstable because of a cross-compilation execline build failure. Check the Hydra for the last known working version.