refactor(nixos): simplify SSH configuration

This commit was merged in pull request #140.
This commit is contained in:
2026-10-07 20:27:48 -04:00
parent 65c84f5b39
commit 3a487050fd
4 changed files with 140 additions and 273 deletions

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus

View File

@@ -4,186 +4,39 @@
registry, registry,
... ...
}: }:
# SSH on this machine, in both directions. # Configures inbound and outbound SSH access.
let let
cfg = config.modules.network.ssh; # NOLINT(let_order) cfg = config.modules.network.ssh;
targetType = types.submodule ( user = config.user.name; # NOLINT(let_order)
{ name, ... }:
{
options = {
hostName = mkOption {
type = types.str;
default = name;
description = ''
The network address OpenSSH connects to for this target.
'';
};
user = mkOption { # NOLINT(let_order)
type = types.str; workstationNames = [
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
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.
'';
};
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.
'';
};
};
}
);
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames)
);
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
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 (
targetName: target:
imap0 (
i: key:
nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) cfg.targets
)
);
knownHostNamesFor =
target:
let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
names = unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
sshSettingsFor =
name:
let
target = cfg.targets.${name};
in
{
header = "Host ${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;
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
;
userKeySecret = "ssh-user-ed25519-key";
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
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" "neogaia"
"zeus" "zeus"
]; ];
description = '' serverNames = [ "pikachu" ];
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 { isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
type = types.attrsOf targetType; isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
default = {
# 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")
];
# NOLINT(let_order)
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
hostName = registry.address.${name}.self;
clientKey = clientKeyFor name;
hostKeys = hostKeysFor name;
});
extraTargets = {
# TODO: Pin Gitea's host key after migrating it from Proxmox.
gitea = { gitea = {
hostName = "git.alexion.dev"; hostName = "git.alexion.dev";
port = 2022; port = 2022;
@@ -193,155 +46,166 @@ in
"git.alexion.dev" "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 = { # NOLINT(let_order)
hostName = registry.address.zeus.self; mkTarget = name: target: {
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus"; hostName = target.hostName or name;
hostKeys = [ user = target.user or user;
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus" port = target.port or 22;
"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" aliases = target.aliases or [ name ];
]; clientKey = target.clientKey or null;
hostKeys = target.hostKeys or [ ];
}; };
targets = lib.mapAttrs mkTarget (managedTargets // extraTargets); # NOLINT(let_order)
pikachu = { clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order)
hostName = registry.address.pikachu.self; # NOLINT(let_order)
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu"; defaultAuthorizedKeys = lib.flatten (
hostKeys = [ clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
"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 { outboundTargetNames = # NOLINT(let_order)
type = types.listOf types.str; let
default = [ ]; groupTargets =
description = '' if isServer then
Additional client public keys admitted by this host. 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;
authorizedKeys = mkOption { knownHosts = lib.listToAttrs (
type = types.nullOr (types.listOf types.str); lib.concatLists (
default = null; lib.mapAttrsToList (
description = '' targetName: target:
Complete override for client public keys admitted by this host. lib.imap0 (
Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`. i: key:
''; lib.nameValuePair "${targetName}-${toString i}" {
}; hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) targets
)
);
knownHostNamesFor =
target:
let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
names = lib.unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
extraSettings = mkOption { keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
type = types.attrsOf types.anything;
default = { };
description = ''
Additional OpenSSH client settings merged into the generated Home Manager configuration.
'';
};
hostKeys.restore = mkOption { sshSettingsFor =
type = types.bool; name:
default = true; let
description = '' target = targets.${name};
Restore the host keys from secrets rather than letting the daemon generate its own. in
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. header = "Host ${lib.concatStringsSep " " target.aliases}";
''; HostName = target.hostName;
}; User = target.user;
}
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
hostKeys.sopsFile = mkOption { hostKeySecret = type: "ssh-host-${type}-key";
type = types.nullOr types.path; hostKeyTypes = [
default = null;
description = ''
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
Required when `restore` is on.
'';
};
hostKeys.types = mkOption {
type = types.listOf types.str;
default = [
"ed25519" "ed25519"
"rsa" "rsa"
]; ];
userKeySecret = "ssh-user-ed25519-key";
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon";
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
default = true;
description = '' description = ''
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers. Restores host keys from secrets.
Preserves the fingerprint across reimages.
Guests disable this option and generate their own host keys.
''; '';
}; };
userKey.sopsFile = mkOption { hostKeys.sopsFile = lib.mkOption {
type = types.nullOr types.path; type = lib.types.nullOr lib.types.path;
default = null; default = null;
description = '' description = ''
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`. Sets encrypted file that contains host private keys.
Left unset on a machine that authenticates to no remote server, such as a guest. Names each secret `ssh-host-<type>-key`.
Requires a value when `restore` is enabled.
'';
};
userKey.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
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 [ config = lib.mkIf cfg.enable (
lib.mkMerge [
{ {
services.openssh.enable = true; services.openssh.enable = true;
users.users.${user}.openssh.authorizedKeys.keys = users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
if cfg.authorizedKeys != null then
cfg.authorizedKeys
else
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
programs.ssh.knownHosts = knownHosts; programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = { home-manager.users.${user}.programs.ssh = {
enable = true; enable = true;
enableDefaultConfig = false; enableDefaultConfig = false;
settings = settings = lib.mapAttrs (name: _: sshSettingsFor name) (
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name)) lib.genAttrs outboundTargetNames (name: name)
// cfg.extraSettings; );
}; };
} }
(mkIf cfg.hostKeys.restore { (lib.mkIf cfg.hostKeys.restore {
assertions = [ assertions = [
{ {
assertion = cfg.hostKeys.sopsFile != null; assertion = cfg.hostKeys.sopsFile != null;
message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys."; message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled.";
} }
]; ];
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: { sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
inherit (cfg.hostKeys) sopsFile; inherit (cfg.hostKeys) sopsFile;
mode = "0400"; mode = "0400";
restartUnits = [ "sshd.service" ]; restartUnits = [ "sshd.service" ];
}); });
services.openssh.hostKeys = [ ]; services.openssh.hostKeys = [ ];
services.openssh.extraConfig = concatStringsSep "" ( services.openssh.extraConfig = lib.concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
); );
}) })
(mkIf (cfg.userKey.sopsFile != null) { (lib.mkIf (cfg.userKey.sopsFile != null) {
sops.secrets.${userKeySecret} = { sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile; inherit (cfg.userKey) sopsFile;
mode = "0400"; mode = "0400";
owner = user; owner = user;
}; };
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile = home-manager.users.${user}.programs.ssh.settings."*" = {
config.sops.secrets.${userKeySecret}.path; IdentityFile = config.sops.secrets.${userKeySecret}.path;
IdentitiesOnly = true;
};
}) })
]); ]
);
} }