From 3a487050fdd407083c068a1ec12e763ae7dc440f Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 7 Oct 2026 20:27:48 -0400 Subject: [PATCH] refactor(nixos): simplify SSH configuration --- .../hosts/neogaia/ssh_user_ed25519_key.pub | 1 + .../hosts/pikachu/ssh_user_ed25519_key.pub | 1 + .../nixos/hosts/zeus/ssh_user_ed25519_key.pub | 1 + configs/nixos/modules/network/ssh.nix | 410 ++++++------------ 4 files changed, 140 insertions(+), 273 deletions(-) create mode 100644 configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub create mode 100644 configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub create mode 100644 configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub diff --git a/configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub b/configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub new file mode 100644 index 0000000..93c84ad --- /dev/null +++ b/configs/nixos/hosts/neogaia/ssh_user_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia diff --git a/configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub b/configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub new file mode 100644 index 0000000..dcbcbb3 --- /dev/null +++ b/configs/nixos/hosts/pikachu/ssh_user_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu diff --git a/configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub b/configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub new file mode 100644 index 0000000..16b5499 --- /dev/null +++ b/configs/nixos/hosts/zeus/ssh_user_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus diff --git a/configs/nixos/modules/network/ssh.nix b/configs/nixos/modules/network/ssh.nix index 4d7c2bb..cf783e4 100644 --- a/configs/nixos/modules/network/ssh.nix +++ b/configs/nixos/modules/network/ssh.nix @@ -4,344 +4,208 @@ registry, ... }: -# SSH on this machine, in both directions. +# Configures inbound and outbound SSH access. let - cfg = config.modules.network.ssh; # NOLINT(let_order) - targetType = types.submodule ( - { name, ... }: - { - options = { - hostName = mkOption { - type = types.str; - default = name; - description = '' - The network address OpenSSH connects to for this target. - ''; - }; + cfg = config.modules.network.ssh; + user = config.user.name; # NOLINT(let_order) - user = mkOption { - type = types.str; - default = config.user.name; - description = '' - The remote login name OpenSSH uses for this target. - ''; - }; + # NOLINT(let_order) + workstationNames = [ + "neogaia" + "zeus" + ]; + serverNames = [ "pikachu" ]; - port = mkOption { - type = types.port; - default = 22; - description = '' - The TCP port OpenSSH uses for this target. - ''; - }; + isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order) + isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order) - aliases = mkOption { - type = types.listOf types.str; - default = [ name ]; - description = '' - Host patterns written into the generated OpenSSH client block. - ''; - }; + # NOLINT(let_order) + clientKeyFor = host: builtins.readFile (../../hosts + "/${host}/ssh_user_ed25519_key.pub"); + hostKeysFor = + host: + map builtins.readFile [ + (../../hosts + "/${host}/ssh_host_ed25519_key.pub") + (../../hosts + "/${host}/ssh_host_rsa_key.pub") + ]; - clientKey = mkOption { - type = types.nullOr types.str; - default = null; - description = '' - The public key this target offers when it connects outward. - Other machines admit this key according to the host groups below. - ''; - }; + # NOLINT(let_order) + managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: { + hostName = registry.address.${name}.self; + clientKey = clientKeyFor name; + hostKeys = hostKeysFor name; + }); - hostKeys = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - The public keys this target presents when it accepts inbound SSH. - These keys generate system-wide known-host entries. - ''; - }; - }; - } - ); + extraTargets = { + # TODO: Pin Gitea's host key after migrating it from Proxmox. + gitea = { + hostName = "git.alexion.dev"; + port = 2022; + user = "gitea"; + aliases = [ + "gitea" + "git.alexion.dev" + ]; + }; + }; + # NOLINT(let_order) + mkTarget = name: target: { + hostName = target.hostName or name; + user = target.user or user; + port = target.port or 22; + aliases = target.aliases or [ name ]; + clientKey = target.clientKey or null; + hostKeys = target.hostKeys or [ ]; + }; + targets = lib.mapAttrs mkTarget (managedTargets // extraTargets); # NOLINT(let_order) + + clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order) + # NOLINT(let_order) defaultAuthorizedKeys = lib.flatten ( - clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames) + clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames) ); - clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names); + outboundTargetNames = # NOLINT(let_order) + let + groupTargets = + if isServer then + builtins.attrNames extraTargets ++ serverNames + else if isWorkstation then + builtins.attrNames extraTargets ++ serverNames ++ workstationNames + else + builtins.attrNames extraTargets; + in + lib.filter (name: name != config.networking.hostName) groupTargets; - workstationNames = targetNamesIn cfg.hosts.workstations; - targetNamesIn = names: filter (name: hasAttr name cfg.targets) names; - - isServer = elem currentHost cfg.hosts.servers; - currentHost = config.networking.hostName; - serverNames = targetNamesIn cfg.hosts.servers; - - knownHosts = listToAttrs ( - concatLists ( - mapAttrsToList ( + knownHosts = lib.listToAttrs ( + lib.concatLists ( + lib.mapAttrsToList ( targetName: target: - imap0 ( + lib.imap0 ( i: key: - nameValuePair "${targetName}-${toString i}" { + lib.nameValuePair "${targetName}-${toString i}" { hostNames = knownHostNamesFor target; publicKey = keyWithoutComment key; } ) target.hostKeys - ) cfg.targets + ) targets ) ); knownHostNamesFor = target: let withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}"; - names = unique (target.aliases ++ [ target.hostName ]); + names = lib.unique (target.aliases ++ [ target.hostName ]); in map withPort names; - keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key)); + keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key)); sshSettingsFor = name: let - target = cfg.targets.${name}; + target = targets.${name}; in { - header = "Host ${concatStringsSep " " target.aliases}"; + header = "Host ${lib.concatStringsSep " " target.aliases}"; HostName = target.hostName; User = target.user; } - // optionalAttrs (target.port != 22) { Port = target.port; }; - - outboundTargetNames = - let - groupTargets = - if isServer then - [ "gitea" ] ++ cfg.hosts.servers - else if isWorkstation then - [ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations - else - [ "gitea" ]; - in - filter (name: name != currentHost) (targetNamesIn groupTargets); - - isWorkstation = elem currentHost cfg.hosts.workstations; + // lib.optionalAttrs (target.port != 22) { Port = target.port; }; hostKeySecret = type: "ssh-host-${type}-key"; - user = config.user.name; - - inherit (lib) - concatLists - concatStringsSep - elem - filter - genAttrs - hasAttr - imap0 - listToAttrs - mapAttrs - mapAttrsToList - mkIf - mkMerge - mkOption - nameValuePair - optional - optionalAttrs - types - unique - ; - + hostKeyTypes = [ + "ed25519" + "rsa" + ]; userKeySecret = "ssh-user-ed25519-key"; in { options.modules.network.ssh = { - enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; + enable = lib.mkEnableOption "the OpenSSH daemon"; - hosts = { - servers = mkOption { - type = types.listOf types.str; - default = [ "pikachu" ]; - description = '' - Hosts that serve durable services. - They admit workstation keys and server keys, and they receive aliases for other servers and the forge. - ''; - }; - - workstations = mkOption { - type = types.listOf types.str; - default = [ - "neogaia" - "zeus" - ]; - description = '' - Hosts the operator works from. - Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge. - ''; - }; - }; - - targets = mkOption { - type = types.attrsOf targetType; - default = { - gitea = { - hostName = "git.alexion.dev"; - port = 2022; - user = "gitea"; - aliases = [ - "gitea" - "git.alexion.dev" - ]; - }; - - neogaia = { - hostName = registry.address.neogaia.self; - clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia"; - hostKeys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia" - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia" - ]; - }; - - zeus = { - hostName = registry.address.zeus.self; - clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus"; - hostKeys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus" - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3xOxGAJCG9PE0FVufgJX9VRO64IUUlRGHSOfYfAI4Mit46xKsr7z/OvqPPE9z8rOIcVPbLkc/mkMuHbDgolleunAFCIeuIIYpkL4o4/TZECh9BtN2WdPhjcD7S0rV+rR7p24Qy5bTCSwZmsQq8QxHTVPjjBngtPGRSpKVrZAuHCcdCJ13hBWAvnaav5KysKC89nMj/7FIh02v8kipGfq5LGCZ20Qp2lmv2DS24NhBPXzcw8pLN47I5GYVkZh/0RWOByteo3eXtKCF+JCwfUPCTP+zHGD+1tbO8//B2fkt3Pe9lUrn5eM2ZnX1emXSWfQng3cMqkftzWqvBwv0sSDU+9YV6H2Ury+GrrfukpBtQVe9PQcBWqbRPhxuagncR+af/tx0ZohZS0t74m3WgLtZ4r2mpTZS4oBmRsfvWzMFGSgwtva+3Y+NsLjPPM1Cz5Dn05JJlidzcABfsUg2H23vkmO2otaQI01dUni+szlRn4w6sVyxNcvY2fQGjmt9k7jmVI5D2A3yl+OLJguAAlw3ia335X1tNUG8FoI/VyZNhOckIihmbTtihNIg37Ro4yiuPRhnUWFvg2hcBCa9hzNP7XDUPnoCRQyJ4adQHQA/x4Kh+HJ6pZqtGcdqU80fzmWtrLkWG6v5/NRKs6soYDup7oQIKbJo9TtXvqtzmVmovQ== root@zeus" - ]; - }; - - pikachu = { - hostName = registry.address.pikachu.self; - clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu"; - hostKeys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu" - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu" - ]; - }; - }; - description = '' - SSH targets known to the fleet. - The inventory holds connection details plus public keys used for authorization and host verification. - ''; - }; - - extraAuthorizedKeys = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - Additional client public keys admitted by this host. - ''; - }; - - authorizedKeys = mkOption { - type = types.nullOr (types.listOf types.str); - default = null; - description = '' - Complete override for client public keys admitted by this host. - Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`. - ''; - }; - - extraSettings = mkOption { - type = types.attrsOf types.anything; - default = { }; - description = '' - Additional OpenSSH client settings merged into the generated Home Manager configuration. - ''; - }; - - hostKeys.restore = mkOption { - type = types.bool; + hostKeys.restore = lib.mkOption { + type = lib.types.bool; default = true; description = '' - Restore the host keys from secrets rather than letting the daemon generate its own. - A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys. - A guest carries no host identity, so it turns this off and presents a self-generated key instead. + Restores host keys from secrets. + Preserves the fingerprint across reimages. + Guests disable this option and generate their own host keys. ''; }; - hostKeys.sopsFile = mkOption { - type = types.nullOr types.path; + hostKeys.sopsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; default = null; description = '' - Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host--key`. - Required when `restore` is on. + Sets encrypted file that contains host private keys. + Names each secret `ssh-host--key`. + Requires a value when `restore` is enabled. ''; }; - hostKeys.types = mkOption { - type = types.listOf 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. - ''; - }; - - userKey.sopsFile = mkOption { - type = types.nullOr types.path; + userKey.sopsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; default = null; description = '' - Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`. - Left unset on a machine that authenticates to no remote server, such as a guest. + Sets encrypted file that contains the SSH client private key. + Names the secret `ssh-user-ed25519-key`. + Guests leave this unset because they do not authenticate to remote servers. ''; }; }; - config = mkIf cfg.enable (mkMerge [ - { - services.openssh.enable = true; + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + services.openssh.enable = true; - users.users.${user}.openssh.authorizedKeys.keys = - if cfg.authorizedKeys != null then - cfg.authorizedKeys - else - defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys; + users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys; - programs.ssh.knownHosts = knownHosts; + programs.ssh.knownHosts = knownHosts; - home-manager.users.${user}.programs.ssh = { - enable = true; - enableDefaultConfig = false; - settings = - mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name)) - // cfg.extraSettings; - }; - } + home-manager.users.${user}.programs.ssh = { + enable = true; + enableDefaultConfig = false; + settings = lib.mapAttrs (name: _: sshSettingsFor name) ( + lib.genAttrs outboundTargetNames (name: name) + ); + }; + } - (mkIf cfg.hostKeys.restore { - assertions = [ - { - assertion = cfg.hostKeys.sopsFile != null; - message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys."; - } - ]; + (lib.mkIf cfg.hostKeys.restore { + assertions = [ + { + assertion = cfg.hostKeys.sopsFile != null; + message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled."; + } + ]; - sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: { - inherit (cfg.hostKeys) sopsFile; - mode = "0400"; - restartUnits = [ "sshd.service" ]; - }); + sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: { + inherit (cfg.hostKeys) sopsFile; + mode = "0400"; + restartUnits = [ "sshd.service" ]; + }); - services.openssh.hostKeys = [ ]; - services.openssh.extraConfig = concatStringsSep "" ( - map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types - ); - }) + services.openssh.hostKeys = [ ]; + services.openssh.extraConfig = lib.concatStringsSep "" ( + map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes + ); + }) - (mkIf (cfg.userKey.sopsFile != null) { - sops.secrets.${userKeySecret} = { - inherit (cfg.userKey) sopsFile; - mode = "0400"; - owner = user; - }; + (lib.mkIf (cfg.userKey.sopsFile != null) { + sops.secrets.${userKeySecret} = { + inherit (cfg.userKey) sopsFile; + mode = "0400"; + owner = user; + }; - home-manager.users.${user}.programs.ssh.settings."*".IdentityFile = - config.sops.secrets.${userKeySecret}.path; - }) - ]); + home-manager.users.${user}.programs.ssh.settings."*" = { + IdentityFile = config.sops.secrets.${userKeySecret}.path; + IdentitiesOnly = true; + }; + }) + ] + ); }