diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index c020473d9..e5375f728 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -58,6 +58,9 @@ def install_command(args: argparse.Namespace) -> None: else: target_host = machine.target_host().override(host_key_check=host_key_check) + if args.identity_file: + target_host = target_host.override(private_key=args.identity_file) + if machine._class_ == "darwin": msg = "Installing macOS machines is not yet supported" raise ClanError(msg) diff --git a/pkgs/clan-cli/clan_lib/dirs/__init__.py b/pkgs/clan-cli/clan_lib/dirs/__init__.py index 4eec08ab9..1b3593d28 100644 --- a/pkgs/clan-cli/clan_lib/dirs/__init__.py +++ b/pkgs/clan-cli/clan_lib/dirs/__init__.py @@ -120,6 +120,12 @@ def user_cache_dir() -> Path: return Path("~/.cache").expanduser() +def user_nixos_anywhere_dir() -> Path: + p = user_config_dir() / "clan" / "nixos-anywhere" + p.mkdir(parents=True, exist_ok=True) + return p + + def user_gcroot_dir() -> Path: p = user_config_dir() / "clan" / "gcroots" p.mkdir(parents=True, exist_ok=True) diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py index 6a758a1b2..ce43d8c04 100644 --- a/pkgs/clan-cli/clan_lib/machines/install.py +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -13,6 +13,7 @@ from clan_lib.api import API from clan_lib.cmd import Log, RunOpts, run from clan_lib.machines.machines import Machine from clan_lib.nix import nix_shell +from clan_lib.ssh.create import create_nixos_anywhere_ssh_key from clan_lib.ssh.remote import Remote log = logging.getLogger(__name__) @@ -25,6 +26,7 @@ BuildOn = Literal["auto", "local", "remote"] class InstallOptions: machine: Machine kexec: str | None = None + anywhere_priv_key: Path | None = None debug: bool = False no_reboot: bool = False phases: str | None = None @@ -115,8 +117,18 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: "IdentitiesOnly=yes", ] + # Always set a nixos-anywhere private key to prevent failures when running + # 'clan install --phases kexec' followed by 'clan install --phases disko,install,reboot'. + # The kexec phase requires an authorized key, and if not specified, + # nixos-anywhere defaults to a key in a temporary directory. + if opts.anywhere_priv_key is None: + key_pair = create_nixos_anywhere_ssh_key() + opts.anywhere_priv_key = key_pair.private + cmd += ["-i", str(opts.anywhere_priv_key)] + + # If we need a different private key for being able to kexec, we can specify it here. if target_host.private_key: - cmd += ["-i", str(target_host.private_key)] + cmd += ["--ssh-option", f"IdentityFile={target_host.private_key}"] if opts.build_on: cmd += ["--build-on", opts.build_on] diff --git a/pkgs/clan-cli/clan_lib/ssh/create.py b/pkgs/clan-cli/clan_lib/ssh/create.py new file mode 100644 index 000000000..e80971ff2 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/ssh/create.py @@ -0,0 +1,61 @@ +import logging +from dataclasses import dataclass +from pathlib import Path + +from clan_lib.api import API +from clan_lib.cmd import Log, RunOpts, run +from clan_lib.dirs import user_nixos_anywhere_dir + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SSHKeyPair: + private: Path + public: Path + + +@API.register +def create_nixos_anywhere_ssh_key() -> SSHKeyPair: + """ + Create a new SSH key pair for NixOS Anywhere. + The keys are stored in ~/.config/clan/nixos-anywhere/keys/id_ed25519 and id_ed25519.pub. + """ + private_key_dir = user_nixos_anywhere_dir() + + key_pair = generate_ssh_key(private_key_dir) + + return key_pair + + +def generate_ssh_key(root_dir: Path) -> SSHKeyPair: + """ + Generate a new SSH key pair at root_dir/keys/id_ed25519 and id_ed25519.pub. + If the key already exists, it will not be regenerated. + """ + key_dir = root_dir / "keys" + key_dir.mkdir(parents=True, exist_ok=True) + key_dir.chmod(0o700) + priv_key = key_dir / "id_ed25519" + + keypair = SSHKeyPair( + private=priv_key, + public=key_dir / "id_ed25519.pub", + ) + + if priv_key.exists(): + return keypair + + log.info(f"Generating nixos-anywhere SSH key pair at {priv_key}") + cmd = [ + "ssh-keygen", + "-N", + "", + "-t", + "ed25519", + "-f", + str(priv_key), + ] + run(cmd, RunOpts(log=Log.BOTH)) + + return keypair diff --git a/pkgs/clan-cli/clan_lib/ssh/create_test.py b/pkgs/clan-cli/clan_lib/ssh/create_test.py new file mode 100644 index 000000000..5cc6c51ca --- /dev/null +++ b/pkgs/clan-cli/clan_lib/ssh/create_test.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from clan_lib.ssh.create import create_nixos_anywhere_ssh_key + + +def test_clan_generate_sshkeys(temporary_home: Path) -> None: + keypair = create_nixos_anywhere_ssh_key() + + assert keypair.private.exists() + assert keypair.public.exists() + assert keypair.private.is_file() + assert keypair.public.is_file() + assert ( + keypair.private.parent + == Path("~/.config/clan/nixos-anywhere/keys").expanduser() + ) + assert ( + keypair.public.parent == Path("~/.config/clan/nixos-anywhere/keys").expanduser() + ) + assert keypair.private.name == "id_ed25519" + assert keypair.public.name == "id_ed25519.pub" + assert "PRIVATE KEY" in keypair.private.read_text() + assert "ssh-ed25519" in keypair.public.read_text() + + new_keypair = create_nixos_anywhere_ssh_key() + + assert new_keypair.private == keypair.private + assert new_keypair.public == keypair.public