feat(neogaia): restore the SSH host keys from sops (task 0011) #4

Merged
alexion merged 5 commits from task-0011-neogaia-ssh-host-keys into main 2026-07-20 09:50:06 -04:00
3 changed files with 75 additions and 32 deletions
Showing only changes of commit 42ff195556 - Show all commits

View File

@@ -50,6 +50,15 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
**No forge CLI is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh` on `PATH` — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not there. **No forge CLI is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh` on `PATH` — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not there.
Credentials, however, do exist: `~/.config/tea/config.yml` holds a token-bearing login named `alexion` (not `axi`), so pull requests **can** be opened with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`. Credentials, however, do exist: `~/.config/tea/config.yml` holds a token-bearing login named `alexion` (not `axi`), so pull requests **can** be opened with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches. The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
A review row with an empty `body` is the normal shape when the operator left only inline comments.
- SSH **host** keys (`ssh_host_<type>_key`, served by the daemon from `/etc/ssh` or a secret) are not user authentication keys (`~/.ssh/id_ed25519`, offered to a remote server).
The `ssh_host_` prefix is OpenSSH's own name for the former, and the `root@<host>` trailing field in a `.pub` is a free-text comment stamped by `ssh-keygen` at generation time, not a claim about which account uses the key.
On this machine the two are provably distinct: the daemon presents `SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM`, while pushes to `git.alexion.dev` authenticate with `SHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA`.
Renaming host keys after user keys, or vice versa, is therefore always wrong.
- `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store. - `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store.
Editing a skill in place fails; its source is `modules/claude-code/skills/<name>/` here, applied by a rebuild. Editing a skill in place fails; its source is `modules/claude-code/skills/<name>/` here, applied by a rebuild.
Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine. Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine.

View File

@@ -1,19 +1,6 @@
{ { inputs, pkgs, ... }:
config,
inputs,
pkgs,
...
}:
# neogaia — Dell XPS 13 9380 laptop. # neogaia — Dell XPS 13 9380 laptop.
# Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here. # Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here.
let
# Read by the daemon at startup, so a re-key has to restart it to take effect.
sshHostKey = {
sopsFile = ../../secrets/neogaia.yaml;
mode = "0400";
restartUnits = [ "sshd.service" ];
};
in
{ {
imports = [ imports = [
inputs.nixos-hardware.nixosModules.dell-xps-13-9380 inputs.nixos-hardware.nixosModules.dell-xps-13-9380
@@ -40,24 +27,10 @@ in
networking.networkmanager.enable = true; networking.networkmanager.enable = true;
# So setup can be driven over the network. # So setup can be driven over the network.
services.openssh.enable = true; # The matching host public keys sit beside this file in plaintext, since
# publishing them is their purpose.
# This host's SSH identity is restored from secrets. modules.ssh.enable = true;
# Reimaging the machine therefore keeps its fingerprint, and every client's modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
# `known_hosts` entry stays valid.
# The matching public halves are committed in plaintext, since publishing
# them is their purpose.
sops.secrets = {
ssh-host-ed25519-key = sshHostKey;
ssh-host-rsa-key = sshHostKey;
};
# An empty list is what stops the daemon generating keys of its own.
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = ''
HostKey ${config.sops.secrets.ssh-host-ed25519-key.path}
HostKey ${config.sops.secrets.ssh-host-rsa-key.path}
'';
# fish as the login shell. # fish as the login shell.
modules.fish.enable = true; modules.fish.enable = true;

61
modules/ssh/ssh.nix Normal file
View File

@@ -0,0 +1,61 @@
{
config,
lib,
...
}:
# The OpenSSH daemon, serving host keys restored from secrets.
let
cfg = config.modules.ssh;
secretName = type: "ssh-host-${type}-key";
in
{
options.modules.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
hostKeys.sopsFile = lib.mkOption {
type = lib.types.path;
description = ''
Encrypted file holding this host's SSH host private keys, one entry per
key type, named `ssh-host-<type>-key`.
These are the keys the daemon presents to identify itself to connecting
clients, not keys used to authenticate anyone to a remote server.
Restoring them from secrets rather than generating them keeps the host's
fingerprint across a reimage, so every client's `known_hosts` entry
stays valid.
'';
};
hostKeys.types = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"ed25519"
"rsa"
];
description = ''
Key types to restore, naming both the entries read from the encrypted
file and the algorithms the daemon offers. Dropping a type a client has
already pinned makes the host unrecognisable to it.
'';
};
};
config = lib.mkIf cfg.enable {
services.openssh.enable = true;
# The daemon reads its host keys once at startup, so a re-key has to restart
# it to take effect.
sops.secrets = lib.genAttrs (map secretName cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
# An empty list is what stops the daemon generating keys of its own.
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatMapStrings (
type: "HostKey ${config.sops.secrets.${secretName type}.path}\n"
) cfg.hostKeys.types;
};
}