Compare commits

...

1 Commits

Author SHA1 Message Date
3a487050fd refactor(nixos): simplify SSH configuration 2026-10-07 20:27:48 -04:00
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,344 +4,208 @@
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; "neogaia"
description = '' "zeus"
The remote login name OpenSSH uses for this target. ];
''; serverNames = [ "pikachu" ];
};
port = mkOption { isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
type = types.port; isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
aliases = mkOption { # NOLINT(let_order)
type = types.listOf types.str; clientKeyFor = host: builtins.readFile (../../hosts + "/${host}/ssh_user_ed25519_key.pub");
default = [ name ]; hostKeysFor =
description = '' host:
Host patterns written into the generated OpenSSH client block. map builtins.readFile [
''; (../../hosts + "/${host}/ssh_host_ed25519_key.pub")
}; (../../hosts + "/${host}/ssh_host_rsa_key.pub")
];
clientKey = mkOption { # NOLINT(let_order)
type = types.nullOr types.str; managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
default = null; hostName = registry.address.${name}.self;
description = '' clientKey = clientKeyFor name;
The public key this target offers when it connects outward. hostKeys = hostKeysFor name;
Other machines admit this key according to the host groups below. });
'';
};
hostKeys = mkOption { extraTargets = {
type = types.listOf types.str; # TODO: Pin Gitea's host key after migrating it from Proxmox.
default = [ ]; gitea = {
description = '' hostName = "git.alexion.dev";
The public keys this target presents when it accepts inbound SSH. port = 2022;
These keys generate system-wide known-host entries. 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 ( 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; knownHosts = lib.listToAttrs (
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names; lib.concatLists (
lib.mapAttrsToList (
isServer = elem currentHost cfg.hosts.servers;
currentHost = config.networking.hostName;
serverNames = targetNamesIn cfg.hosts.servers;
knownHosts = listToAttrs (
concatLists (
mapAttrsToList (
targetName: target: targetName: target:
imap0 ( lib.imap0 (
i: key: i: key:
nameValuePair "${targetName}-${toString i}" { lib.nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target; hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key; publicKey = keyWithoutComment key;
} }
) target.hostKeys ) target.hostKeys
) cfg.targets ) targets
) )
); );
knownHostNamesFor = knownHostNamesFor =
target: target:
let let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}"; 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 in
map withPort names; map withPort names;
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key)); keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
sshSettingsFor = sshSettingsFor =
name: name:
let let
target = cfg.targets.${name}; target = targets.${name};
in in
{ {
header = "Host ${concatStringsSep " " target.aliases}"; header = "Host ${lib.concatStringsSep " " target.aliases}";
HostName = target.hostName; HostName = target.hostName;
User = target.user; User = target.user;
} }
// optionalAttrs (target.port != 22) { Port = target.port; }; // lib.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"; hostKeySecret = type: "ssh-host-${type}-key";
user = config.user.name; hostKeyTypes = [
"ed25519"
inherit (lib) "rsa"
concatLists ];
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
userKeySecret = "ssh-user-ed25519-key"; userKeySecret = "ssh-user-ed25519-key";
in in
{ {
options.modules.network.ssh = { options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets"; enable = lib.mkEnableOption "the OpenSSH daemon";
hosts = { hostKeys.restore = lib.mkOption {
servers = mkOption { type = lib.types.bool;
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;
default = true; default = true;
description = '' description = ''
Restore the host keys from secrets rather than letting the daemon generate its own. Restores host keys from secrets.
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys. Preserves the fingerprint across reimages.
A guest carries no host identity, so it turns this off and presents a self-generated key instead. Guests disable this option and generate their own host keys.
''; '';
}; };
hostKeys.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 host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`. Sets encrypted file that contains host private keys.
Required when `restore` is on. Names each secret `ssh-host-<type>-key`.
Requires a value when `restore` is enabled.
''; '';
}; };
hostKeys.types = mkOption { userKey.sopsFile = lib.mkOption {
type = types.listOf types.str; type = lib.types.nullOr lib.types.path;
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;
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 the SSH client private key.
Left unset on a machine that authenticates to no remote server, such as a guest. 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;
]); };
})
]
);
} }