refactor(nixos): simplify SSH configuration

This commit is contained in:
2026-10-07 18:52:36 -04:00
parent 65c84f5b39
commit cea61eef3d

View File

@@ -4,186 +4,26 @@
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; # 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.
'';
};
user = mkOption {
type = types.str;
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 ( defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ optional isServer (clientKeysFor serverNames) clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
); );
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names);
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names); targets = lib.mapAttrs mkTarget defaultTargets;
mkTarget = name: target: {
workstationNames = targetNamesIn cfg.hosts.workstations; hostName = target.hostName or name;
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names; user = target.user or user;
port = target.port or 22;
isServer = elem currentHost cfg.hosts.servers; aliases = target.aliases or [ name ];
currentHost = config.networking.hostName; clientKey = target.clientKey or null;
serverNames = targetNamesIn cfg.hosts.servers; hostKeys = target.hostKeys or [ ];
};
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; user = config.user.name;
inherit (lib) defaultTargets = {
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"
"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 = { gitea = {
hostName = "git.alexion.dev"; hostName = "git.alexion.dev";
port = 2022; port = 2022;
@@ -221,119 +61,145 @@ in
]; ];
}; };
}; };
description = '' workstationNames = [
SSH targets known to the fleet. "neogaia"
The inventory holds connection details plus public keys used for authorization and host verification. "zeus"
''; ];
}; isServer = lib.elem currentHost serverNames;
currentHost = config.networking.hostName;
serverNames = [ "pikachu" ];
extraAuthorizedKeys = mkOption { knownHosts = lib.listToAttrs (
type = types.listOf types.str; lib.concatLists (
default = [ ]; lib.mapAttrsToList (
description = '' targetName: target:
Additional client public keys admitted by this host. lib.imap0 (
''; 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;
authorizedKeys = mkOption { keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
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 { sshSettingsFor =
type = types.attrsOf types.anything; name:
default = { }; let
description = '' target = targets.${name};
Additional OpenSSH client settings merged into the generated Home Manager configuration. in
''; {
}; header = "Host ${lib.concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
hostKeys.restore = mkOption { outboundTargetNames =
type = types.bool; let
default = true; groupTargets =
description = '' if isServer then
Restore the host keys from secrets rather than letting the daemon generate its own. [ "gitea" ] ++ serverNames
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys. else if isWorkstation then
A guest carries no host identity, so it turns this off and presents a self-generated key instead. [ "gitea" ] ++ serverNames ++ workstationNames
''; else
}; [ "gitea" ];
in
lib.filter (name: name != currentHost) groupTargets;
isWorkstation = lib.elem currentHost workstationNames;
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";
@@ -343,5 +209,6 @@ in
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile = home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
config.sops.secrets.${userKeySecret}.path; config.sops.secrets.${userKeySecret}.path;
}) })
]); ]
);
} }