#| file: modules/nixos/desktop/ssh.nix
{
lib,
config,
pkgs,
...
}:
let
cfg = config.mjm.desktop;
in
{
options.mjm.desktop = {
<<options>>
};
config = lib.mkIf cfg.enable {
<<config>>
};
_class = "nixos";
}On my desktop machines, I use SSH a lot to connect to my various servers, and I have some special configuration for it.
ssh.mode = lib.mkOption {
type = lib.types.enum [
"tpm"
"yubikey"
];
default = if config.security.tpm2.enable then "tpm" else "yubikey";
};I have two modes of configuring SSH on my desktop machines. If the machine has a TPM, it will use ssh-tpm-agent to manage the key. Otherwise, I'll use SSH's support for storing keys on a YubiKey. The correct value for this will be set based on whether the TPM is enabled on the machine.
ssh.keyDir = lib.mkOption {
type = lib.types.pathWith { };
default = "~/.ssh";
};
ssh.publicKeyName = lib.mkOption {
type = lib.types.str;
default =
{
tpm = "id_ecdsa.pub";
yubikey = "id_ed25519_sk.pub";
}
.${cfg.ssh.mode};
};
ssh.publicKeyPath = lib.mkOption {
type = lib.types.pathWith { };
default = "${cfg.ssh.keyDir}/${cfg.ssh.publicKeyName}";
};These options configure the location of the SSH public key. This is used both for configuring SSH and for signing SSH certificates based on the public key. The SSH key path should be set correctly automatically based on the mode set above.
ssh.privateKeyPath = lib.mkOption {
type = lib.types.pathWith { };
default = lib.replaceString ".pub" "" cfg.ssh.publicKeyPath;
};The private key path is generally only needed for the YubiKey mode, and is set by stripping off the ".pub" extension from the public key path.
ssh.certPath = lib.mkOption {
type = lib.types.pathWith { };
default = "~/.ssh/${lib.replaceString ".pub" "-cert.pub" cfg.ssh.publicKeyName}";
};The certificate path has a name based on the public key's name, but it's generally always stored in ~/.ssh, even if the keyDir is set to something else, in case it's set to something read-only.
programs.ssh.enableAskPassword = true;
Desktop systems generally want to be able to use an SSH askpass program to prompt for passphrases and such. In NixOS, this defaults to whether X11 is enabled, but I don't really want to enable that because it brings other cruft with it that I don't need for Wayland. So I'll just set it here instead.
programs.ssh.extraConfig =
let
updateCert = pkgs.execline.writeScript "update-ssh-cert" "-P" ''
<<update-cert>>
'';
in
''
Match localuser !root,* host *
${lib.optionalString (cfg.ssh.mode == "yubikey") "IdentityFile ${cfg.ssh.privateKeyPath}"}
${lib.optionalString (
cfg.ssh.mode == "tpm"
) "IdentityAgent \${XDG_RUNTIME_DIR}/ssh-tpm-agent.sock"}
Match localuser !root,* host *.home.mattmoriarity.com,5.78.46.61,152.53.116.186 exec \"${updateCert}\"
${lib.optionalString (cfg.ssh.mode != "yubikey") "IdentityFile ${cfg.ssh.publicKeyPath}"}
CertificateFile ${cfg.ssh.certPath}
'';There's two parts to the SSH configuration here.
The first part applies for all SSH connections. If the machine is using a YubiKey for SSH, then SSH is configured to use that private key as the identity for all connections. If the machine is using the TPM for SSH, then SSH is configured to use the ssh-tpm-agent.
The second part only applies to my server hosts. These hosts are configured to accept certificates signed by my Vault instance, so I have additional configuration to set the key and certificate files correctly for those hosts. There is also an "exec" match rule for this section, which lets me run a script before connecting to these hosts. I use this to update the SSH certificate file using Vault, since these certificates have a limited lifetime. This script is written in execline and is covered in detail below.
#| id: update-cert export VAULT_ADDR https://vault.midna.dev importas -S HOME
First, I'll need to use the HOME environment variable, so that is imported. And I'll be using the vault CLI tool, so that needs to know how to connect to Vault.
#| id: update-cert
define cert_path ${lib.replaceString "~" "\${HOME}" cfg.ssh.certPath}
define tmp_cert_path ''${cert_path}.tmpThe $cert_path variable will be the full path to the SSH certificate. Some of the commands using the path will not properly expand a ~ to the home directory, so I replace that with ${HOME}, which should get replaced with the actual home directory via the "importas" above.
To avoid overwriting an existing certificate file if issuing a new one fails, I'm going to write the new certificate to a temporary path first, before moving it into the final location.
#| id: update-cert
ifelse
{
<<vault-login>>
<<sign-certificate>>
}
{ mv $tmp_cert_path $cert_path }
foreground { rm $tmp_cert_path }
exit 1And here is how that's done. The command will first check if the Vault token is still valid, and if not it will attempt to login. Then it will try to sign an SSH certificate for the configured public key and write it to $tmp_cert_path. If that succeeds, then the temporary cert path is moved to the proper location. But if it fails, the temporary file is removed and the script exits with a failing code.
#| id: vault-login
foreground
{
if -n
{
redirfd -w 1 /dev/null
redirfd -w 2 /dev/null
${lib.getExe pkgs.vault} token lookup
}
${lib.getExe pkgs.vault} login -method=oidc
}The foreground wrapper here ensures that the script continues even if the "if" check fails, which is desired here. The command "vault token lookup" is used to see if the token is valid. It's a bit crude but it should fail to exit successfully when the token is expired or not present. The two "redirfd" commands before it are to suppress the output of the command: only the exit code is interesting here. If the token lookup fails, then "vault login -method=oidc" is run, which should open a browser to sign in to Vault with OpenID Connect. Once the browser handshake is done, the vault command should exit and allow the rest of the script to continue.
#| id: sign-certificate
redirfd -w 1 $tmp_cert_path
${lib.getExe pkgs.vault} write
-field=signed_key
ssh-client-signer/sign/homelab-client
public_key=@${lib.replaceString "~" "\${HOME}" cfg.ssh.publicKeyPath}
valid_principals=mjmSigning the certificate is a single request to Vault using "vault write". The stdout of the command gets written to $tmp_cert_path. When the command succeeds, that output should be the certificate contents thanks to the "-field=signed_key" argument.
text/gemini;lang=en-USThis content has been proxied by September (UNKNO).