diff --git a/CLAUDE.md b/CLAUDE.md index e85339c..c251336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. 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 ...`. 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 --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//comments` — that endpoint holds only top-level discussion and is usually empty. + Inline comments need two calls: `/pulls//reviews` for the review ids, then `/pulls//reviews//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__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@` 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. Editing a skill in place fails; its source is `modules/claude-code/skills//` 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. diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index a7d6293..1c04a4e 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -1,19 +1,6 @@ -{ - config, - inputs, - pkgs, - ... -}: +{ inputs, pkgs, ... }: # neogaia — Dell XPS 13 9380 laptop. # 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 = [ inputs.nixos-hardware.nixosModules.dell-xps-13-9380 @@ -40,24 +27,10 @@ in networking.networkmanager.enable = true; # So setup can be driven over the network. - services.openssh.enable = true; - - # This host's SSH identity is restored from secrets. - # Reimaging the machine therefore keeps its fingerprint, and every client's - # `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} - ''; + # The matching host public keys sit beside this file in plaintext, since + # publishing them is their purpose. + modules.ssh.enable = true; + modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; # fish as the login shell. modules.fish.enable = true; diff --git a/modules/ssh/ssh.nix b/modules/ssh/ssh.nix new file mode 100644 index 0000000..8a62c4c --- /dev/null +++ b/modules/ssh/ssh.nix @@ -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--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; + }; +}