feat(neogaia): restore the SSH host keys from sops (task 0011) #4
@@ -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 <branch> ...`.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
modules/ssh/ssh.nix
Normal file
61
modules/ssh/ssh.nix
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
|
alexion marked this conversation as resolved
Outdated
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user
This is a single-file module, no need for a folder.