diff --git a/.sops.yaml b/.sops.yaml index cd00cc4..aad69e9 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -5,12 +5,12 @@ # REF;https://github.com/nix-community/infra/blob/428dc48b4b08c7b02a9512b0c60b84d30c6acce0/.sops.yaml keys: # ERROR; Age's extension for Yubikey derived age-keys is not yet merged into sops! - # REF; + # REF; https://github.com/getsops/sops/pull/1465 #- &yubikey_bert_proesmans age1yubikey1... # VAULT transit keychain - &master "http://169.254.245.1:8200/v1/sops/keys/master" # HOST KEYS - #- &host_buddy age1... + - &host_buddy age14an6m226h8vu06nv5q83s7vl59ytq8j9dkaujvrwgsdj98kr0ukq0a5k0g # NOTE; These rules are in effect when using the SOPS CLI. # Both creation of- and running the command updatekeys will modify the key material of files with sensitive content. @@ -18,22 +18,22 @@ keys: # WARN; Creation rules are interpreted sequentially, and a first match is used to calculate/provide key material # to the file argument. creation_rules: - - path_regex: hosts/[^/]+/keys.encrypted.yaml$ + - path_regex: hosts/[^/]+/keys\.encrypted\.yaml$ key_groups: # NOTE; A single key group comprised of 2 different types of encryption keys/methods # By default, to decrypt, at least one decryption key for _each_ keygroup must be present to decrypt secrets. # Since there is exactly one keygroup, any key within can fully decrypt the secrets - age: #- *yubikey_bert_proesmans - hc_vault_transit_uri: - - *master + hc_vault: + - *master - path_regex: hosts/buddy/[^/]+\.encrypted\.yaml$ key_groups: - age: - - *host_buddy - hc_vault_transit_uri: - - *master + - *host_buddy + hc_vault: + - *master # NOTE; No path_regex as fallback option # @@ -41,5 +41,5 @@ creation_rules: - key_groups: - age: #- *yubikey_bert_proesmans - hc_vault_transit_uri: - - *master \ No newline at end of file + hc_vault: + - *master \ No newline at end of file diff --git a/flake.nix b/flake.nix index 487e948..959c222 100644 --- a/flake.nix +++ b/flake.nix @@ -137,7 +137,15 @@ }; # Software directly available inside the developer shell - packages = builtins.attrValues { inherit (pkgs) nyancat git vault; }; + packages = builtins.attrValues { + inherit (pkgs) + # For fun + nyancat figlet + # For development + git bat vault + # For secret material + sops ssh-to-age; + }; VAULT_ADDR = "http://169.254.245.1:8200"; VAULT_TOKEN = "; run export VAULT_TOKEN=''"; diff --git a/library/facts.nix b/library/facts.nix index 986b54a..4219098 100644 --- a/library/facts.nix +++ b/library/facts.nix @@ -7,6 +7,7 @@ _lib: buddy.net.management.mac = "4a:5c:7c:d1:8a:35"; #buddy.net.management.ipv4 = "192.168.88.10"; - vm.dns.net.mac = "4e:72:72:20:a5:2f"; + #vm.dns.net.mac = "4e:72:72:20:a5:2f"; + vm.idm.net.mac = "9e:30:e8:e8:b1:d0"; }; } diff --git a/nixosModules/hosts/buddy/default.nix b/nixosModules/hosts/buddy/default.nix index 31f4f53..d473430 100644 --- a/nixosModules/hosts/buddy/default.nix +++ b/nixosModules/hosts/buddy/default.nix @@ -366,15 +366,30 @@ mountpoint = "/var"; options.mountpoint = "legacy"; # Filesystem at boot required, prevent duplicate mount }; - #"safe" = { }; - #"safe/persist" = { - # "safe/persist/vm" = { - "safe/persist/vm/state" = { - # Stores all state between reboots in a single location - # at file/folder granularity + #"persist" = { }; + "persist/home" = { + # User data + type = "zfs_fs"; + # WARN; Potential race while mounting, see the note about zfs generators + # REF; https://github.com/NixOS/nixpkgs/issues/212762 + mountpoint = "/home"; + # Workaround sync hang with SQLite WAL + # REF; https://github.com/openzfs/zfs/issues/14290 + # See also `overlays.atuin`! + # options.sync = "disabled"; + }; + "persist/replicate" = { + # State to be sent/received from cluster + type = "zfs_fs"; + mountpoint = "/replicate"; + }; + "persist/vm" = { + # Default storage location for vm state data without requirements. + # HELP; Create sub datasets to specialize storage behaviour to the application. type = "zfs_fs"; options = { - mountpoint = "/vm-state"; + canmount = "off"; + mountpoint = "/vm"; # Qemu does its own application level caching # NOTE; Set to none if you'd be storing raw- or qcow backed volumes. primarycache = "metadata"; @@ -390,21 +405,14 @@ setuid = "off"; }; }; - "safe/home" = { - # User data - type = "zfs_fs"; - # WARN; Potential race while mounting, see the note about zfs generators - # REF; https://github.com/NixOS/nixpkgs/issues/212762 - mountpoint = "/home"; - # Workaround sync hang with SQLite WAL - # REF; https://github.com/openzfs/zfs/issues/14290 - # See also `overlays.atuin`! - # options.sync = "disabled"; - }; - "replicate" = { - # State to be sent/received from cluster + "persist/vm/kanidm" = { + # Kanidm state is basically a database. This dataset is tuned for that use case. type = "zfs_fs"; - mountpoint = "/replicate"; + options = { + mountpoint = "/vm/kanidm"; # Default, but good to be explicit + logbias = "latency"; + recordsize = "64K"; + }; }; }; }; @@ -638,22 +646,44 @@ # }; # }; - # kanidm = { - # autostart = true; - # flake = null; - # updateFlake = null; - # specialArgs = { inherit profiles; }; - - # # The configuration for the MicroVM. - # # Multiple definitions will be merged as expected. - # config = { - # networking.hostName = "SSO"; - # imports = [ profiles.micro-vm ]; - - # # Any other configuration for your MicroVM - # # [...] - # }; - # }; + kanidm = { + autostart = true; + specialArgs = { inherit profiles; }; + + # The configuration for the MicroVM. + # Multiple definitions will be merged as expected. + config = { + networking.hostName = "SSO"; + imports = [ profiles.micro-vm ]; + + microvm.interfaces = [{ + type = "tap"; + id = "tap-kanidm"; + mac = lib.facts.vm.idm.net.mac; + }]; + + microvm.shares = [{ + source = "/vm/kanidm"; + mountPoint = "/var/lib/kanidm"; + tag = "kanidm"; + proto = "virtiofs"; + }]; + + services.kanidm = { + enableServer = true; + serverSettings = { + bindaddress = ""; + domain = "idm.proesmans.eu"; + origin = "https://idm.proesmans.eu"; + tls_chain = ""; + tls_key = ""; + db_fs_type = "zfs"; + role = "WriteReplica"; + online_backup.versions = 0; # disable online backup + }; + }; + }; + }; }; # Ignore below diff --git a/nixosModules/hosts/buddy/keys.encrypted.yaml b/nixosModules/hosts/buddy/keys.encrypted.yaml new file mode 100644 index 0000000..d4177f4 --- /dev/null +++ b/nixosModules/hosts/buddy/keys.encrypted.yaml @@ -0,0 +1,17 @@ +ssh_host_ed25519_key: ENC[AES256_GCM,data:8zS1ZFfulZcz6JibMFMJu4SoZi7girKAX4VOJ5Bu8YH+t6Cmq3c1bjtvEIX9SKsg3ylmTnfpkQIIkf9uKj0AiyjsL/KjSus5GnlEkXDOz6Vg6YhxSQR0EgoLmbpZ207bqurh0N1r26ot2E/N7QZpc8RgC6nEhsSuouN2Go7kklk2OUqBhrIsRCwxeh6IgwSrVsSg3sEoGb9gBwB5x6MnBsHrBzt3Txpd2VcJWyCIv+6sZ1XdFLFp7PTQaNgk9Iq00UI0MhzFFkvyvZCNwemt3YVCBQUre8vH/5pwWVBR80FISx8QSzDy3jfZczmaKKxlu0UiWYucUg23rjX6R99DO9e/atap2ScXjxwXv3DiGNYG8DzCYfL70fg4mgWD3EHf6RJQkDa7ma3kDHoWCdP20mTZx7meKZMI0yPlbbNS1XNuklPR6BAacY+or8AG7aIQV2V0Q/WBgW40gmC7ek43VcWw5H5xVj3JuI5HMLK0RgOGuwOxZH7FpOfXRkkLqDR9KrJ4XKTJHOjQetIHZ4h9GhCZLSo5O29snJuvsZHAVJmgJss=,iv:Z7RETt9octI1LykaB/k6qR2VFnqX7h9WzZIMBQLzopA=,tag:F0rDdxpRJ7f2N82tqqyR0w==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: + - vault_address: http://169.254.245.1:8200 + engine_path: sops + key_name: master + created_at: "2024-05-06T20:54:33Z" + enc: vault:v1:rSKb1kjPrDz92ddFnMictY9QcnwOHRLsUTRM3zXwWhN6k7bhutPcTpUOZznLurof5B2nChpt3jcRzatC + age: [] + lastmodified: "2024-05-06T20:54:33Z" + mac: ENC[AES256_GCM,data:zO2IHQkdpY+JpkY8gti738/PxGprAFYMz/CeavFDG9dwRW47KP5Bin+hUO/K8QxQ9s2B2PT41wxw6R2/q4HbT8txH6Eh4bTqOMxL/rasjD40jgVYphDkW4IJVcarm+BGmgbK3RwFdBYuwftzwTF1naIMp0Le3xl0/0LpIZ4POkY=,iv:c2QkoDmLmB7QWs5zC40MaJj5dzX3WpO7LSFttCJtQi8=,tag:kZMa6FsvKLCYFty8eHIvDw==,type:str] + pgp: [] + unencrypted_suffix: _unencrypted + version: 3.8.1 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..21e6930 --- /dev/null +++ b/tasks.py @@ -0,0 +1,211 @@ +import os +from pathlib import Path +from typing import Any, Union +from shlex import quote +import subprocess +from tempfile import TemporaryDirectory +import json + +# REF; https://www.pyinvoke.org/ +from invoke import task +# REF; https://github.com/numtide/deploykit/ +from deploykit import DeployHost + + +INVOKED_PATH = Path.cwd() + +FLAKE = Path(__file__).parent.resolve() +os.chdir(FLAKE) + +@task +# USAGE: invoke check +def check(c: Any) -> None: + """ + Evaluate and build all outputs from the flake common schema, including all attribute sets from the output 'checks'. + This command does not stop executing after encountering an error, and will run until all tasks have ended. + """ + c.run("nix flake check --keep-going") + +@task +# USAGE: invoke check +def format(c: Any) -> None: + """ + Format the source code of this repository. + """ + c.run("nix fmt") + +@task +def update_sops_files(c: Any) -> None: + """ + Update all sops yaml files according to .sops.yaml rules + """ + environment = os.environ.copy() + subprocess.run( + """ + find . -type f \\( -iname '*.encrypted.yaml' \\) -print0 | \ + xargs -0 -n1 sops updatekeys --yes + """ + , env=environment, shell=True, check=True + ) + +def private_opener(path: str, flags: int) -> Union[str, int]: + return os.open(path, flags, 0o400) + +def decrypt_host_key(flake_attr: str, tmpdir: str) -> None: + # Location of encrypted keys for the specified system configuration + keys_file = FLAKE / "nixosModules" / "hosts" / flake_attr / "keys.encrypted.yaml" + + # Prepare filepath and secure file access to store sensitive key material + tmp = Path(tmpdir) + tmp.mkdir(parents=True, exist_ok=True) + tmp.chmod(0o755) + host_key = tmp / "etc/ssh/ssh_host_ed25519_key" + host_key.parent.mkdir(parents=True, exist_ok=True) + + with open(host_key, "w", opener=private_opener) as key_handle: + environment = os.environ.copy() + environment["SOPS_AGE_KEY"] = decrypt_dev_key() + + # Decrypt the keys file, extract the value of key 'ssh_host_ed25519_key', push the decrypted value + # to stdout, redirect stdout to the file at /tmp + # + # ERROR; Explicit program and argument syntax (list/bracket form), because we're not using + # the shell as intermediate command interpreter + subprocess.run( + [ + 'sops' + , '--extract' + , '["ssh_host_ed25519_key"]' + , '--decrypt' + , quote(keys_file.as_posix()) + ] + , env=environment + , check=True + , stdout=key_handle + ) + +@task +# USAGE; invoke deploy --flake-attr development --hostname 10.1.7.100 +def deploy(c: Any, flake_attr: str, hostname: str) -> None: + """ + Decrypt the private SSH hostkey of the target machine, deploy the machine, upload the private hostkey to + the host filesystem. + Use this command to do initial configuration (aka installation) of new hosts. + """ + ask = input(f"Install configuration {flake_attr} on {hostname}? [y/N] ") + if ask != "y": + return + + with TemporaryDirectory() as tmpdir: + decrypt_host_key(flake_attr, tmpdir) + + deploy_flags = "--debug" + #deploy_flags += " --no-reboot" + + # NOTE; Flakes can give hints to the nix CLI to change runtime behaviours, like adding a binary cache for + # operations on that flake execution only. + # These options are encoded inside the 'nixConfig' output attribute of the flake-schema. + # REF; https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-flake.html#flake-format + # + #deploy_flags += " --option accept-flake-config true" + + environment = os.environ.copy() + # ERROR; Cannot use sops --exec-file because we need to pass a full file structure to nixos-anywhere + subprocess.run( + f""" + nix run nixpkgs#nixos-anywhere -- {hostname} --extra-files {tmpdir} --flake .#{flake_attr} {deploy_flags} + """ + , env=environment, shell=True, check=True + ) + +@task +# USAGE; invoke secret-edit nixosModules/hosts/development/secrets.yaml +def secret_edit(c: Any, file_path: str) -> None: + """ + Load the decryption key from the keyserver, decrypt the development key, start sops to edit the plaintext secrets of the provided file + """ + + # WARN; Path to existing file (for editing), or path to non-existant file for creation by SOPS. + encrypted_file = (INVOKED_PATH / file_path).absolute() + assert encrypted_file.name.endswith("encrypted.yaml"), """ + The convention is to end the filename of encrypted sensitive content with *.encrypted.yaml. + Update the provided path argument to align with the above convention! + """ + + environment = os.environ.copy() + subprocess.run(f"sops \"{quote(encrypted_file.as_posix())}\"", env=environment, shell=True, check=True) + +@task +# USAGE; invoke create-host-key nixosModules/hosts/development/keys.encrypted.yaml +def create_host_key(c: Any, file_path: str) -> None: + """ + Create and encrypt a new SSH private host key. + Use this command when defining a new host configuration. This is a required step before executing `invoke deploy `. + """ + + # WARN; Path to existing file (for editing), or path to non-existant file for creation by SOPS. + encrypted_file = (INVOKED_PATH / file_path).absolute() + assert encrypted_file.name.endswith("encrypted.yaml"), """ + The convention is to end the filename of encrypted sensitive content with *.encrypted.yaml. + Update the provided path argument to align with the above convention! + """ + + assert not encrypted_file.exists(), """ + The designated file to store the encrypted key material already exists. This task will not overwrite that file! + If it's intentional to overwrite the host key, delete the encrypted file and retry. + """ + + with TemporaryDirectory() as tmpdir: + # Prepare filepath and secure file access to store sensitive key material + tmp = Path(tmpdir) + tmp.mkdir(parents=True, exist_ok=True) + tmp.chmod(0o755) + host_key = tmp / "ssh_host_ed25519_key" + pub_host_key = host_key.with_suffix(".pub") + file_to_encrypt = tmp / "keys.json" + + # Create a new key of type 'ed25519', written to the designated filepath + # + # ERROR; Explicit program and argument syntax (list/bracket form), because we're not using + # the shell as intermediate command interpreter + subprocess.run( + [ + 'ssh-keygen' + , '-t', 'ed25519' + , '-N', '' # No password + , '-f', host_key.as_posix() + ] + , check=True + ) + + # Write out a json file with the key material + with open(host_key, "r", opener=private_opener) as key_handle: + with open(file_to_encrypt, "w", opener=private_opener) as to_encrypt_handle: + data = {"ssh_host_ed25519_key": key_handle.read()} + json.dump(data, to_encrypt_handle) + + # Encrypt the json file with SOPS + # ERROR; It's not possible to programmatically instruct sops to create an encrypted file with exact contents. + # The argument is that sops is not a secrets manager, but a secrets editor.. + subprocess.run( + [ + 'sops' + , '--encrypt' + , '--input-type', 'json' + # WARN; Explicit conversion json->yaml because I don't want to pull in a python library to + # process yaml files correctly.. sops can do this :D + , '--output-type', 'yaml' + , '--output', encrypted_file.as_posix() # Write directly into the output file + # Input file + , file_to_encrypt.as_posix() + ] + , check=True + ) + + c.run(f'echo "AGE PUB KEY to use in sops config"; cat "{quote(pub_host_key.as_posix())}" | ssh-to-age') + print( + """ + Insert the age-key into the sops.yaml file, + and follow up `invoke update-sops-files` to rekey the encrypted files + """ + )