The client role now automatically collects and merges searchDomains from ALL servers in the instance when not explicitly configured. This eliminates redundant configuration and ensures clients trust certificates from all servers. Also uses lib.mkIf with .exists check to safely handle the openssh-cert generator access, checking searchDomains first to enable lazy evaluation.
223 lines
8.0 KiB
Nix
223 lines
8.0 KiB
Nix
{ ... }:
|
||
{
|
||
_class = "clan.service";
|
||
manifest.name = "clan-core/sshd";
|
||
manifest.description = "Enables secure remote access to the machine over SSH with automatic host key management and optional CA-signed host certificates.";
|
||
manifest.categories = [
|
||
"System"
|
||
"Network"
|
||
];
|
||
manifest.readme = builtins.readFile ./README.md;
|
||
|
||
roles.client = {
|
||
description = "Installs the SSH CA public key into known_hosts for the configured domains, so this machine can verify servers’ host certificates without TOFU prompts.";
|
||
interface =
|
||
{ lib, ... }:
|
||
{
|
||
options.certificate = {
|
||
searchDomains = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "mydomain.com" ];
|
||
description = ''
|
||
List of domains to include in the certificate.
|
||
This option will prepend the machine name in front of each domain
|
||
before adding it to the certificate.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
perInstance =
|
||
{ settings, roles, ... }:
|
||
{
|
||
nixosModule =
|
||
{
|
||
config,
|
||
lib,
|
||
pkgs,
|
||
...
|
||
}:
|
||
let
|
||
uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||
# Collect searchDomains from all servers in this instance
|
||
allServerSearchDomains = lib.flatten (
|
||
lib.mapAttrsToList (_name: machineConfig: machineConfig.settings.certificate.searchDomains or [ ]) (
|
||
roles.server.machines or { }
|
||
)
|
||
);
|
||
# Merge client's searchDomains with all servers' searchDomains
|
||
searchDomains = uniqueStrings (settings.certificate.searchDomains ++ allServerSearchDomains);
|
||
in
|
||
{
|
||
clan.core.vars.generators.openssh-ca = lib.mkIf (searchDomains != [ ]) {
|
||
share = true;
|
||
files.id_ed25519.deploy = false;
|
||
files."id_ed25519.pub" = {
|
||
deploy = false;
|
||
secret = false;
|
||
};
|
||
runtimeInputs = [
|
||
pkgs.openssh
|
||
];
|
||
script = ''
|
||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
|
||
'';
|
||
};
|
||
|
||
programs.ssh.knownHosts.ssh-ca = lib.mkIf (searchDomains != [ ]) {
|
||
certAuthority = true;
|
||
extraHostNames = builtins.map (domain: "*.${domain}") searchDomains;
|
||
publicKey = config.clan.core.vars.generators.openssh-ca.files."id_ed25519.pub".value;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
roles.server = {
|
||
description = "Runs sshd with persistent host keys and (if certificate.searchDomains is set) a CA‑signed host certificate for <machine>.<domain>, enabling TOFU‑less verification by clients that trust the CA.";
|
||
interface =
|
||
{ lib, ... }:
|
||
{
|
||
options = {
|
||
hostKeys.rsa.enable = lib.mkEnableOption "generating a RSA host key";
|
||
|
||
certificate = {
|
||
searchDomains = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "mydomain.com" ];
|
||
description = ''
|
||
List of domains to include in the certificate. This option will
|
||
prepend the machine name in front of each domain before adding
|
||
it to the certificate.
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
perInstance =
|
||
{ settings, ... }:
|
||
{
|
||
nixosModule =
|
||
{
|
||
config,
|
||
lib,
|
||
pkgs,
|
||
...
|
||
}:
|
||
{
|
||
clan.core.vars.generators = {
|
||
openssh-ca = lib.mkIf (settings.certificate.searchDomains != [ ]) {
|
||
share = true;
|
||
files.id_ed25519.deploy = false;
|
||
files."id_ed25519.pub" = {
|
||
deploy = false;
|
||
secret = false;
|
||
};
|
||
runtimeInputs = [
|
||
pkgs.openssh
|
||
];
|
||
script = ''
|
||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
|
||
'';
|
||
};
|
||
|
||
openssh-cert = lib.mkIf (settings.certificate.searchDomains != [ ]) {
|
||
files."ssh.id_ed25519-cert.pub".secret = false;
|
||
dependencies = [
|
||
"openssh"
|
||
"openssh-ca"
|
||
];
|
||
validation = {
|
||
name = config.clan.core.settings.machine.name;
|
||
domains = lib.genAttrs settings.certificate.searchDomains lib.id;
|
||
};
|
||
runtimeInputs = [
|
||
pkgs.openssh
|
||
pkgs.jq
|
||
];
|
||
script =
|
||
let
|
||
stringSet = list: builtins.attrNames (builtins.groupBy lib.id list);
|
||
domains = stringSet settings.certificate.searchDomains;
|
||
in
|
||
''
|
||
ssh-keygen \
|
||
-s $in/openssh-ca/id_ed25519 \
|
||
-I ${config.clan.core.settings.machine.name} \
|
||
-h \
|
||
-n ${lib.concatMapStringsSep "," (d: "${config.clan.core.settings.machine.name}.${d}") domains} \
|
||
$in/openssh/ssh.id_ed25519.pub
|
||
mv $in/openssh/ssh.id_ed25519-cert.pub "$out"/ssh.id_ed25519-cert.pub
|
||
'';
|
||
};
|
||
|
||
openssh-rsa = lib.mkIf settings.hostKeys.rsa.enable {
|
||
files."ssh.id_rsa" = { };
|
||
files."ssh.id_rsa.pub".secret = false;
|
||
runtimeInputs = [
|
||
pkgs.coreutils
|
||
pkgs.openssh
|
||
];
|
||
script = ''
|
||
ssh-keygen -t rsa -b 4096 -N "" -C "" -f "$out"/ssh.id_rsa
|
||
'';
|
||
};
|
||
|
||
openssh = {
|
||
files."ssh.id_ed25519" = { };
|
||
files."ssh.id_ed25519.pub".secret = false;
|
||
migrateFact = "openssh";
|
||
runtimeInputs = [
|
||
pkgs.coreutils
|
||
pkgs.openssh
|
||
];
|
||
script = ''
|
||
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/ssh.id_ed25519
|
||
'';
|
||
};
|
||
};
|
||
|
||
programs.ssh.knownHosts.ssh-ca = lib.mkIf (settings.certificate.searchDomains != [ ]) {
|
||
certAuthority = true;
|
||
extraHostNames = builtins.map (domain: "*.${domain}") settings.certificate.searchDomains;
|
||
publicKey = config.clan.core.vars.generators.openssh-ca.files."id_ed25519.pub".value;
|
||
};
|
||
|
||
services.openssh = {
|
||
enable = true;
|
||
settings.PasswordAuthentication = false;
|
||
|
||
settings.HostCertificate = lib.mkIf (
|
||
# this check needs to go first, as otherwise generators.openssh-cert does not exist
|
||
settings.certificate.searchDomains != [ ]
|
||
&& config.clan.core.vars.generators.openssh-cert.files."ssh.id_ed25519-cert.pub".exists
|
||
) config.clan.core.vars.generators.openssh-cert.files."ssh.id_ed25519-cert.pub".path;
|
||
|
||
hostKeys = [
|
||
{
|
||
path = config.clan.core.vars.generators.openssh.files."ssh.id_ed25519".path;
|
||
type = "ed25519";
|
||
}
|
||
]
|
||
++ lib.optional settings.hostKeys.rsa.enable {
|
||
path = config.clan.core.vars.generators.openssh-rsa.files."ssh.id_rsa".path;
|
||
type = "rsa";
|
||
};
|
||
};
|
||
|
||
programs.ssh.knownHosts.clan-sshd-self-ed25519 = {
|
||
hostNames = [
|
||
"localhost"
|
||
config.networking.hostName
|
||
]
|
||
++ (lib.optional (config.networking.domain != null) config.networking.fqdn);
|
||
publicKey = config.clan.core.vars.generators.openssh.files."ssh.id_ed25519.pub".value;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|