diff --git a/nixosModules/clanCore/vars/interface.nix b/nixosModules/clanCore/vars/interface.nix index 87c335c5a..4f179380e 100644 --- a/nixosModules/clanCore/vars/interface.nix +++ b/nixosModules/clanCore/vars/interface.nix @@ -207,11 +207,14 @@ in description = '' This option determines when the secret will be decrypted and deployed to the target machine. + By setting this to `partitioning`, the secret will be deployed prior to running `disko` allowing + you to manage filesystem encryption keys. These will only be deployed when installing the system. By setting this to `activation`, the secret will be deployed prior to running `nixos-rebuild` or `nixos-install`. By setting this to `user`, the secret will be deployed prior to users and groups are created, allowing users' passwords to be managed by vars. The secret will be stored in `/run/secrets-for-users` and `owner` and `group` must be `root`. ''; type = lib.types.enum [ + "partitioning" "activation" "users" "services" diff --git a/nixosModules/clanCore/vars/secret/password-store.nix b/nixosModules/clanCore/vars/secret/password-store.nix index d2cfbddec..dd9713108 100644 --- a/nixosModules/clanCore/vars/secret/password-store.nix +++ b/nixosModules/clanCore/vars/secret/password-store.nix @@ -42,6 +42,7 @@ let useSystemdActivation = (options.systemd ? sysusers && config.systemd.sysusers.enable) || (options.services ? userborn && config.services.userborn.enable); + normalSecrets = lib.any ( gen: lib.any (file: file.neededFor == "services") (lib.attrValues gen.files) ) (lib.attrValues config.clan.core.vars.generators); @@ -75,7 +76,9 @@ in else if file.config.neededFor == "services" then "/run/secrets/${file.config.generatorName}/${file.config.name}" else if file.config.neededFor == "activation" then - "${config.clan.password-store.secretLocation}/${file.config.generatorName}/${file.config.name}" + "${config.clan.password-store.secretLocation}/activation/${file.config.generatorName}/${file.config.name}" + else if file.config.neededFor == "partitioning" then + "/run/partitioning-secrets/${file.config.generatorName}/${file.config.name}" else throw "unknown neededFor ${file.config.neededFor}"; diff --git a/nixosModules/clanCore/vars/secret/sops/default.nix b/nixosModules/clanCore/vars/secret/sops/default.nix index 06d0c88de..d2453ae8c 100644 --- a/nixosModules/clanCore/vars/secret/sops/default.nix +++ b/nixosModules/clanCore/vars/secret/sops/default.nix @@ -25,8 +25,10 @@ in # Before we generate a secret we cannot know the path yet, so we need to set it to an empty string fileModule = file: { path = lib.mkIf file.config.secret ( - if file.config.neededFor == "activation" then - "/var/lib/sops-nix/${file.config.generatorName}/${file.config.name}" + if file.config.neededFor == "partitioning" then + "/run/partitioning-secrets/${file.config.generatorName}/${file.config.name}" + else if file.config.neededFor == "activation" then + "/var/lib/sops-nix/activation/${file.config.generatorName}/${file.config.name}" else config.sops.secrets.${"vars/${file.config.generatorName}/${file.config.name}"}.path or "/no-such-path" diff --git a/nixosModules/clanCore/vars/secret/sops/funcs.nix b/nixosModules/clanCore/vars/secret/sops/funcs.nix index ed3e3d6b1..0afd839bb 100644 --- a/nixosModules/clanCore/vars/secret/sops/funcs.nix +++ b/nixosModules/clanCore/vars/secret/sops/funcs.nix @@ -17,7 +17,9 @@ in let relevantFiles = generator: - filterAttrs (_name: f: f.secret && f.deploy && (f.neededFor != "activation")) generator.files; + filterAttrs ( + _name: f: f.secret && f.deploy && (f.neededFor == "users" || f.neededFor == "services") + ) generator.files; allFiles = flatten ( mapAttrsToList ( gen_name: generator: diff --git a/nixosModules/clanCore/vars/secret/vm.nix b/nixosModules/clanCore/vars/secret/vm.nix index fcd6e82b4..006ee0d45 100644 --- a/nixosModules/clanCore/vars/secret/vm.nix +++ b/nixosModules/clanCore/vars/secret/vm.nix @@ -6,7 +6,11 @@ { config.clan.core.vars.settings = lib.mkIf (config.clan.core.vars.settings.secretStore == "vm") { fileModule = file: { - path = "/etc/secrets/${file.config.generatorName}/${file.config.name}"; + path = + if file.config.neededFor == "partitioning" then + "/run/partitioning-secrets/${file.config.generatorName}/${file.config.name}" + else + "/etc/secrets/${file.config.generatorName}/${file.config.name}"; }; secretModule = "clan_cli.vars.secret_modules.vm"; }; diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index 7d60f78bd..1f525e8c1 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -53,16 +53,20 @@ def install_machine(opts: InstallOptions) -> None: generate_facts([machine]) generate_vars([machine]) - with TemporaryDirectory(prefix="nixos-install-") as tmpdir_: - tmpdir = Path(tmpdir_) - upload_dir_ = machine.secrets_upload_directory - - if upload_dir_.startswith("/"): - upload_dir_ = upload_dir_[1:] - upload_dir = tmpdir / upload_dir_ + with TemporaryDirectory(prefix="nixos-install-") as base_directory: + activation_secrets = Path(base_directory) / "activation_secrets" + upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") upload_dir.mkdir(parents=True) machine.secret_facts_store.upload(upload_dir) - machine.secret_vars_store.populate_dir(upload_dir) + machine.secret_vars_store.populate_dir( + upload_dir, phases=["activation", "users", "services"] + ) + + partitioning_secrets = Path(base_directory) / "partitioning_secrets" + partitioning_secrets.mkdir(parents=True) + machine.secret_vars_store.populate_dir( + partitioning_secrets, phases=["partitioning"] + ) if opts.password: os.environ["SSHPASS"] = opts.password @@ -72,9 +76,22 @@ def install_machine(opts: InstallOptions) -> None: "--flake", f"{machine.flake}#{machine.name}", "--extra-files", - str(tmpdir), + str(activation_secrets), ] + for path in partitioning_secrets.rglob("*"): + if path.is_file(): + cmd.extend( + [ + "--disk-encryption-keys", + str( + "/run/partitioning-secrets" + / path.relative_to(partitioning_secrets) + ), + str(path), + ] + ) + if opts.no_reboot: cmd.append("--no-reboot") diff --git a/pkgs/clan-cli/clan_cli/vars/_types.py b/pkgs/clan-cli/clan_cli/vars/_types.py index c3442dca7..7269cd625 100644 --- a/pkgs/clan-cli/clan_cli/vars/_types.py +++ b/pkgs/clan-cli/clan_cli/vars/_types.py @@ -155,5 +155,9 @@ class StoreBase(ABC): return stored_hash == target_hash @abstractmethod - def populate_dir(self, output_dir: Path) -> None: + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + pass + + @abstractmethod + def upload(self, phases: list[str]) -> None: pass diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py index 80e0292a5..a4a5a2697 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/in_repo.py @@ -50,6 +50,10 @@ class FactStore(StoreBase): def exists(self, generator: Generator, name: str) -> bool: return (self.directory(generator, name) / "value").exists() - def populate_dir(self, output_dir: Path) -> None: + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) + + def upload(self, phases: list[str]) -> None: + msg = "upload is not implemented for public vars stores" + raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py index 536cbc0ab..cb5619e9c 100644 --- a/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/public_modules/vm.py @@ -48,6 +48,10 @@ class FactStore(StoreBase): msg = f"Fact {name} for service {generator.name} not found" raise ClanError(msg) - def populate_dir(self, output_dir: Path) -> None: + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: msg = "populate_dir is not implemented for public vars stores" raise NotImplementedError(msg) + + def upload(self, phases: list[str]) -> None: + msg = "upload is not implemented for public vars stores" + raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py index e2a401e3e..9d111fb4b 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/password_store.py @@ -152,52 +152,77 @@ class SecretStore(StoreBase): return local_hash.decode() != remote_hash - def populate_dir(self, output_dir: Path) -> None: - with ( - tarfile.open(output_dir / "secrets.tar.gz", "w:gz") as tar, - tarfile.open(output_dir / "secrets_for_users.tar.gz", "w:gz") as user_tar, - ): + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + if "users" in phases: + with tarfile.open( + output_dir / "secrets_for_users.tar.gz", "w:gz" + ) as user_tar: + for generator in self.machine.vars_generators: + dir_exists = False + for file in generator.files: + if not file.deploy: + continue + if not file.secret: + continue + tar_file = tarfile.TarInfo(name=f"{generator.name}/{file.name}") + content = self.get(generator, file.name) + tar_file.size = len(content) + tar_file.mode = file.mode + user_tar.addfile(tarinfo=tar_file, fileobj=io.BytesIO(content)) + + if "services" in phases: + with tarfile.open(output_dir / "secrets.tar.gz", "w:gz") as tar: + for generator in self.machine.vars_generators: + dir_exists = False + for file in generator.files: + if not file.deploy: + continue + if not file.secret: + continue + if not dir_exists: + tar_dir = tarfile.TarInfo(name=generator.name) + tar_dir.type = tarfile.DIRTYPE + tar_dir.mode = 0o511 + tar.addfile(tarinfo=tar_dir) + dir_exists = True + tar_file = tarfile.TarInfo(name=f"{generator.name}/{file.name}") + content = self.get(generator, file.name) + tar_file.size = len(content) + tar_file.mode = file.mode + tar_file.uname = file.owner + tar_file.gname = file.group + tar.addfile(tarinfo=tar_file, fileobj=io.BytesIO(content)) + if "activation" in phases: for generator in self.machine.vars_generators: - dir_exists = False for file in generator.files: if file.needed_for == "activation": - (output_dir / generator.name / file.name).parent.mkdir( - parents=True, - exist_ok=True, + out_file = ( + output_dir / "activation" / generator.name / file.name ) - (output_dir / generator.name / file.name).write_bytes( - self.get(generator, file.name) + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_bytes(self.get(generator, file.name)) + if "partitioning" in phases: + for generator in self.machine.vars_generators: + for file in generator.files: + if file.needed_for == "partitioning": + out_file = ( + output_dir / "partitioning" / generator.name / file.name ) - continue - if not file.deploy: - continue - if not file.secret: - continue - if not dir_exists and file.needed_for == "services": - tar_dir = tarfile.TarInfo(name=generator.name) - tar_dir.type = tarfile.DIRTYPE - tar_dir.mode = 0o511 - tar.addfile(tarinfo=tar_dir) - dir_exists = True - tar_file = tarfile.TarInfo(name=f"{generator.name}/{file.name}") - content = self.get(generator, file.name) - tar_file.size = len(content) - tar_file.mode = file.mode - tar_file.uname = file.owner - tar_file.gname = file.group - if file.needed_for == "users": - user_tar.addfile(tarinfo=tar_file, fileobj=io.BytesIO(content)) - else: - tar.addfile(tarinfo=tar_file, fileobj=io.BytesIO(content)) + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_bytes(self.get(generator, file.name)) + (output_dir / ".pass_info").write_bytes(self.generate_hash()) - def upload(self) -> None: + def upload(self, phases: list[str]) -> None: + if "partitioning" in phases: + msg = "Cannot upload partitioning secrets" + raise NotImplementedError(msg) if not self.needs_upload(): log.info("Secrets already uploaded") return with TemporaryDirectory(prefix="vars-upload-") as tempdir: pass_dir = Path(tempdir) - self.populate_dir(pass_dir) + self.populate_dir(pass_dir, phases) upload_dir = Path( self.machine.deployment["password-store"]["secretLocation"] ) diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py index dbf3128c9..5d80ab9f0 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/sops.py @@ -163,34 +163,56 @@ class SecretStore(StoreBase): self.machine.flake_dir, self.secret_path(generator, name) ).encode("utf-8") - def populate_dir(self, output_dir: Path) -> None: - key_name = f"{self.machine.name}-age.key" - if not has_secret(sops_secrets_folder(self.machine.flake_dir) / key_name): - # skip uploading the secret, not managed by us - return - key = decrypt_secret( - self.machine.flake_dir, - sops_secrets_folder(self.machine.flake_dir) / key_name, - ) - (output_dir / "key.txt").touch(mode=0o600) - (output_dir / "key.txt").write_text(key) - for generator in self.machine.vars_generators: - for file in generator.files: - if file.needed_for == "activation": - target_path = output_dir / generator.name / file.name - target_path.parent.mkdir( - parents=True, - exist_ok=True, - ) - # chmod after in case it doesn't have u+w - target_path.touch(mode=0o600) - target_path.write_bytes(self.get(generator, file.name)) - target_path.chmod(file.mode) + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: + if "users" in phases or "services" in phases: + key_name = f"{self.machine.name}-age.key" + if not has_secret(sops_secrets_folder(self.machine.flake_dir) / key_name): + # skip uploading the secret, not managed by us + return + key = decrypt_secret( + self.machine.flake_dir, + sops_secrets_folder(self.machine.flake_dir) / key_name, + ) + (output_dir / "key.txt").touch(mode=0o600) + (output_dir / "key.txt").write_text(key) - def upload(self) -> None: + if "activation" in phases: + for generator in self.machine.vars_generators: + for file in generator.files: + if file.needed_for == "activation": + target_path = ( + output_dir / "activation" / generator.name / file.name + ) + target_path.parent.mkdir( + parents=True, + exist_ok=True, + ) + # chmod after in case it doesn't have u+w + target_path.touch(mode=0o600) + target_path.write_bytes(self.get(generator, file.name)) + target_path.chmod(file.mode) + + if "partitioning" in phases: + for generator in self.machine.vars_generators: + for file in generator.files: + if file.needed_for == "partitioning": + target_path = output_dir / generator.name / file.name + target_path.parent.mkdir( + parents=True, + exist_ok=True, + ) + # chmod after in case it doesn't have u+w + target_path.touch(mode=0o600) + target_path.write_bytes(self.get(generator, file.name)) + target_path.chmod(file.mode) + + def upload(self, phases: list[str]) -> None: + if "partitioning" in phases: + msg = "Cannot upload partitioning secrets" + raise NotImplementedError(msg) with TemporaryDirectory(prefix="sops-upload-") as tempdir: sops_upload_dir = Path(tempdir) - self.populate_dir(sops_upload_dir) + self.populate_dir(sops_upload_dir, phases) upload(self.machine.target_host, sops_upload_dir, Path("/var/lib/sops-nix")) def exists(self, generator: Generator, name: str) -> bool: diff --git a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py index a484af564..e623f0f96 100644 --- a/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py +++ b/pkgs/clan-cli/clan_cli/vars/secret_modules/vm.py @@ -32,11 +32,18 @@ class SecretStore(StoreBase): secret_file.write_bytes(value) return None # we manage the files outside of the git repo + def exists(self, generator: "Generator", name: str) -> bool: + return (self.dir / generator.name / name).exists() + def get(self, generator: Generator, name: str) -> bytes: secret_file = self.dir / generator.name / name return secret_file.read_bytes() - def populate_dir(self, output_dir: Path) -> None: + def populate_dir(self, output_dir: Path, phases: list[str]) -> None: if output_dir.exists(): shutil.rmtree(output_dir) shutil.copytree(self.dir, output_dir) + + def upload(self, phases: list[str]) -> None: + msg = "Cannot upload secrets to VMs" + raise NotImplementedError(msg) diff --git a/pkgs/clan-cli/clan_cli/vars/upload.py b/pkgs/clan-cli/clan_cli/vars/upload.py index e71977f8a..c59285135 100644 --- a/pkgs/clan-cli/clan_cli/vars/upload.py +++ b/pkgs/clan-cli/clan_cli/vars/upload.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) def upload_secret_vars(machine: Machine) -> None: secret_store_module = importlib.import_module(machine.secret_vars_module) secret_store = secret_store_module.SecretStore(machine=machine) - secret_store.upload() + secret_store.upload(phases=["activation", "users", "services"]) def upload_command(args: argparse.Namespace) -> None: