diff --git a/lib/jsonschema/default.nix b/lib/jsonschema/default.nix index 26c399e45..90a8fb2bd 100644 --- a/lib/jsonschema/default.nix +++ b/lib/jsonschema/default.nix @@ -7,6 +7,7 @@ let float = "number"; int = "integer"; str = "string"; + path = "string"; # TODO add prober path checks }; # remove _module attribute from options @@ -103,6 +104,13 @@ rec { type = "string"; } + # parse string + else if option.type.name == "path" + # return jsonschema property definition for path + then default // description // { + type = "string"; + } + # parse enum else if option.type.name == "enum" # return jsonschema property definition for enum diff --git a/nixosModules/clanCore/flake-module.nix b/nixosModules/clanCore/flake-module.nix index da174fff9..868a52375 100644 --- a/nixosModules/clanCore/flake-module.nix +++ b/nixosModules/clanCore/flake-module.nix @@ -3,6 +3,7 @@ imports = [ ./secrets ./zerotier.nix + ./networking.nix inputs.sops-nix.nixosModules.sops # just some example options. Can be removed later ./bloatware diff --git a/nixosModules/clanCore/networking.nix b/nixosModules/clanCore/networking.nix new file mode 100644 index 000000000..813939df3 --- /dev/null +++ b/nixosModules/clanCore/networking.nix @@ -0,0 +1,15 @@ +{ config, lib, ... }: +{ + options.clan.networking = { + deploymentAddress = lib.mkOption { + description = '' + The target SSH node for deployment. + + By default, the node's attribute name will be used. + If set to null, only local deployment will be supported. + ''; + type = lib.types.nullOr lib.types.str; + default = "root@${config.networking.hostName}"; + }; + }; +} diff --git a/nixosModules/clanCore/secrets/default.nix b/nixosModules/clanCore/secrets/default.nix index 48fef2956..700f5d078 100644 --- a/nixosModules/clanCore/secrets/default.nix +++ b/nixosModules/clanCore/secrets/default.nix @@ -1,6 +1,16 @@ -{ config, lib, ... }: +{ config, lib, pkgs, ... }: { + options.clanCore.secretStore = lib.mkOption { + type = lib.types.enum [ "sops" "password-store" "custom" ]; + default = "sops"; + description = '' + method to store secrets + custom can be used to define a custom secret store. + one would have to define system.clan.generateSecrets and system.clan.uploadSecrets + ''; + }; options.clanCore.secrets = lib.mkOption { + default = { }; type = lib.types.attrsOf (lib.types.submodule (secret: { options = { @@ -49,10 +59,10 @@ description = '' path to a fact which is generated by the generator ''; - default = "${config.clanCore.clanDir}/machines/${config.clanCore.machineName}/facts/${fact.config._module.args.name}"; + default = "machines/${config.clanCore.machineName}/facts/${fact.config._module.args.name}"; }; value = lib.mkOption { - default = builtins.readFile fact.config.path; + default = builtins.readFile "${config.clanCore.clanDir}/fact.config.path"; }; }; })); @@ -60,7 +70,12 @@ }; })); }; + config.system.build.generateUploadSecrets = pkgs.writeScript "generate_upload_secrets" '' + ${config.system.clan.generateSecrets} + ${config.system.clan.uploadSecrets} + ''; imports = [ - ./sops.nix # for now we have only one implementation, thats why we import it here and not in clanModules + ./sops.nix + ./password-store.nix ]; } diff --git a/nixosModules/clanCore/secrets/password-store.nix b/nixosModules/clanCore/secrets/password-store.nix new file mode 100644 index 000000000..3db20e03a --- /dev/null +++ b/nixosModules/clanCore/secrets/password-store.nix @@ -0,0 +1,118 @@ +{ config, lib, pkgs, ... }: +let + passwordstoreDir = "\${PASSWORD_STORE_DIR:-$HOME/.password-store}"; +in +{ + options.clan.password-store.targetDirectory = lib.mkOption { + type = lib.types.path; + default = "/etc/secrets"; + description = '' + The directory where the password store is uploaded to. + ''; + }; + config = lib.mkIf (config.clanCore.secretStore == "password-store") { + system.clan.generateSecrets = pkgs.writeScript "generate-secrets" '' + #!/bin/sh + set -efu + + test -d "$CLAN_DIR" + PATH=${lib.makeBinPath [ + pkgs.pass + ]}:$PATH + + # TODO maybe initialize password store if it doesn't exist yet + + ${lib.foldlAttrs (acc: n: v: '' + ${acc} + # ${n} + # if any of the secrets are missing, we regenerate all connected facts/secrets + (if ! ${lib.concatMapStringsSep " && " (x: "pass show machines/${config.clanCore.machineName}/${x.name} >/dev/null") (lib.attrValues v.secrets)}; then + + facts=$(mktemp -d) + trap "rm -rf $facts" EXIT + secrets=$(mktemp -d) + trap "rm -rf $secrets" EXIT + ${v.generator} + + ${lib.concatMapStrings (fact: '' + mkdir -p "$(dirname ${fact.path})" + cp "$facts"/${fact.name} "$CLAN_DIR"/${fact.path} + '') (lib.attrValues v.facts)} + + ${lib.concatMapStrings (secret: '' + cat "$secrets"/${secret.name} | pass insert -m machines/${config.clanCore.machineName}/${secret.name} + '') (lib.attrValues v.secrets)} + fi) + '') "" config.clanCore.secrets} + ''; + system.clan.uploadSecrets = pkgs.writeScript "upload-secrets" '' + #!/bin/sh + set -efu + + target=$1 + + umask 0077 + + PATH=${lib.makeBinPath [ + pkgs.pass + pkgs.git + pkgs.findutils + pkgs.rsync + ]}:$PATH:${lib.getBin pkgs.openssh} + + if test -e ${passwordstoreDir}/.git; then + local_pass_info=$( + git -C ${passwordstoreDir} log -1 --format=%H machines/${config.clanCore.machineName} + # we append a hash for every symlink, otherwise we would miss updates on + # files where the symlink points to + find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type l \ + -exec realpath {} + | + sort | + xargs -r -n 1 git -C ${passwordstoreDir} log -1 --format=%H + ) + remote_pass_info=$(ssh "$target" -- ${lib.escapeShellArg '' + cat ${config.clan.password-store.targetDirectory}/.pass_info || : + ''}) + + if test "$local_pass_info" = "$remote_pass_info"; then + echo secrets already match + exit 0 + fi + fi + + tmp_dir=$(mktemp -dt populate-pass.XXXXXXXX) + trap cleanup EXIT + cleanup() { + rm -fR "$tmp_dir" + } + + find ${passwordstoreDir}/machines/${config.clanCore.machineName} -type f -follow ! -name .gpg-id | + while read -r gpg_path; do + + rel_name=''${gpg_path#${passwordstoreDir}} + rel_name=''${rel_name%.gpg} + + pass_date=$( + if test -e ${passwordstoreDir}/.git; then + git -C ${passwordstoreDir} log -1 --format=%aI "$gpg_path" + fi + ) + pass_name=$rel_name + tmp_path=$tmp_dir/$(basename $rel_name) + + mkdir -p "$(dirname "$tmp_path")" + pass show "$pass_name" > "$tmp_path" + if [ -n "$pass_date" ]; then + touch -d "$pass_date" "$tmp_path" + fi + done + + if test -n "''${local_pass_info-}"; then + echo "$local_pass_info" > "$tmp_dir"/.pass_info + fi + + rsync --mkpath --delete -a "$tmp_dir"/ "$target":${config.clan.password-store.targetDirectory}/ + ''; + }; +} + diff --git a/nixosModules/clanCore/secrets/sops.nix b/nixosModules/clanCore/secrets/sops.nix index ab9772282..25d0af6e6 100644 --- a/nixosModules/clanCore/secrets/sops.nix +++ b/nixosModules/clanCore/secrets/sops.nix @@ -21,11 +21,12 @@ let secrets = filterDir containsMachineOrGroups secretsDir; in { - config = { + config = lib.mkIf (config.clanCore.secretStore == "sops") { system.clan.generateSecrets = pkgs.writeScript "generate-secrets" '' #!/bin/sh set -efu - set -x # remove for prod + + test -d "$CLAN_DIR" PATH=$PATH:${lib.makeBinPath [ config.clanCore.clanPkgs.clan-cli @@ -55,7 +56,7 @@ in ${lib.concatMapStrings (fact: '' mkdir -p "$(dirname ${fact.path})" - cp "$facts"/${fact.name} ${fact.path} + cp "$facts"/${fact.name} "$CLAN_DIR"/${fact.path} '') (lib.attrValues v.facts)} ${lib.concatMapStrings (secret: '' @@ -64,6 +65,9 @@ in fi) '') "" config.clanCore.secrets} ''; + system.clan.uploadSecrets = pkgs.writeScript "upload-secrets" '' + echo upload is not needed for sops secret store, since the secrets are part of the flake + ''; sops.secrets = builtins.mapAttrs (name: _: { sopsFile = config.clanCore.clanDir + "/sops/secrets/${name}/secret"; diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index bed009f83..963b0a15c 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -1,7 +1,13 @@ import argparse import json +import os import subprocess +from typing import Optional +from ..dirs import get_clan_flake_toplevel +from ..nix import nix_eval +from ..secrets.generate import generate_secrets +from ..secrets.upload import upload_secrets from ..ssh import Host, HostGroup, HostKeyCheck @@ -13,11 +19,13 @@ def deploy_nixos(hosts: HostGroup) -> None: def deploy(h: Host) -> None: target = f"{h.user or 'root'}@{h.host}" ssh_arg = f"-p {h.port}" if h.port else "" + env = os.environ.copy() + env["NIX_SSHOPTS"] = ssh_arg res = h.run_local( ["nix", "flake", "archive", "--to", f"ssh://{target}", "--json"], check=True, stdout=subprocess.PIPE, - extra_env=dict(NIX_SSHOPTS=ssh_arg), + extra_env=env, ) data = json.loads(res.stdout) path = data["path"] @@ -29,6 +37,9 @@ def deploy_nixos(hosts: HostGroup) -> None: ssh_arg += " -i " + h.key if h.key else "" + generate_secrets(h.host) + upload_secrets(h.host) + flake_attr = h.meta.get("flake_attr", "") if flake_attr: flake_attr = "#" + flake_attr @@ -67,20 +78,36 @@ def deploy_nixos(hosts: HostGroup) -> None: # FIXME: we want some kind of inventory here. def update(args: argparse.Namespace) -> None: - meta = {} - if args.flake_uri: - meta["flake_uri"] = args.flake_uri - if args.flake_attr: - meta["flake_attr"] = args.flake_attr - deploy_nixos(HostGroup([Host(args.host, user=args.user, meta=meta)])) + clan_dir = get_clan_flake_toplevel().as_posix() + host = json.loads( + subprocess.run( + nix_eval( + [ + f'{clan_dir}#nixosConfigurations."{args.machine}".config.clan.networking.deploymentAddress' + ] + ), + stdout=subprocess.PIPE, + check=True, + text=True, + ).stdout + ) + parts = host.split("@") + user: Optional[str] = None + if len(parts) > 1: + user = parts[0] + hostname = parts[1] + else: + hostname = parts[0] + maybe_port = hostname.split(":") + port = None + if len(maybe_port) > 1: + hostname = maybe_port[0] + port = int(maybe_port[1]) + print(f"deploying {host}") + deploy_nixos(HostGroup([Host(host=hostname, port=port, user=user)])) def register_update_parser(parser: argparse.ArgumentParser) -> None: - # TODO pass all args we don't parse into ssh_args, currently it fails if arg starts with - - parser.add_argument("--flake-uri", type=str, default=".#", help="nix flake uri") - parser.add_argument( - "--flake-attr", type=str, help="nixos configuration in the flake" - ) - parser.add_argument("--user", type=str, default="root") - parser.add_argument("host", type=str) + parser.add_argument("--target-host", type=str, default="root") + parser.add_argument("machine", type=str) parser.set_defaults(func=update) diff --git a/pkgs/clan-cli/clan_cli/nix.py b/pkgs/clan-cli/clan_cli/nix.py index 559b82387..0a34face1 100644 --- a/pkgs/clan-cli/clan_cli/nix.py +++ b/pkgs/clan-cli/clan_cli/nix.py @@ -1,43 +1,18 @@ -import json import os import tempfile -from pathlib import Path -from .dirs import get_clan_flake_toplevel, nixpkgs_flake, nixpkgs_source, unfree_nixpkgs +from .dirs import nixpkgs_flake, nixpkgs_source, unfree_nixpkgs -def nix_build_machine( - machine: str, attr: list[str], flake_url: Path | None = None +def nix_build( + flags: list[str], ) -> list[str]: - if flake_url is None: - flake_url = get_clan_flake_toplevel() - payload = json.dumps( - dict( - clan_flake=flake_url, - machine=machine, - attr=attr, - ) - ) - escaped_payload = json.dumps(payload) return [ "nix", "build", - "--impure", + "--no-link", "--print-out-paths", - "--expr", - f"let args = builtins.fromJSON {escaped_payload}; in " - """ - let - flake = builtins.getFlake args.clan_flake; - config = flake.nixosConfigurations.${args.machine}.extendModules { - modules = [{ - clanCore.clanDir = args.clan_flake; - }]; - }; - in - flake.inputs.nixpkgs.lib.getAttrFromPath args.attr config - """, - ] + ] + flags def nix_eval(flags: list[str]) -> list[str]: diff --git a/pkgs/clan-cli/clan_cli/secrets/__init__.py b/pkgs/clan-cli/clan_cli/secrets/__init__.py index 3c161d3b5..01ac958d2 100644 --- a/pkgs/clan-cli/clan_cli/secrets/__init__.py +++ b/pkgs/clan-cli/clan_cli/secrets/__init__.py @@ -7,6 +7,7 @@ from .import_sops import register_import_sops_parser from .key import register_key_parser from .machines import register_machines_parser from .secrets import register_secrets_parser +from .upload import register_upload_parser from .users import register_users_parser @@ -36,6 +37,9 @@ def register_parser(parser: argparse.ArgumentParser) -> None: ) register_generate_parser(parser_generate) + parser_upload = subparser.add_parser("upload", help="upload secrets for machines") + register_upload_parser(parser_upload) + parser_key = subparser.add_parser("key", help="create and show age keys") register_key_parser(parser_key) diff --git a/pkgs/clan-cli/clan_cli/secrets/generate.py b/pkgs/clan-cli/clan_cli/secrets/generate.py index 0b01a8c85..782bb555c 100644 --- a/pkgs/clan-cli/clan_cli/secrets/generate.py +++ b/pkgs/clan-cli/clan_cli/secrets/generate.py @@ -1,24 +1,25 @@ import argparse +import os import subprocess import sys from clan_cli.errors import ClanError +from ..dirs import get_clan_flake_toplevel +from ..nix import nix_build + + +def generate_secrets(machine: str) -> None: + clan_dir = get_clan_flake_toplevel().as_posix().strip() + env = os.environ.copy() + env["CLAN_DIR"] = clan_dir -def get_secret_script(machine: str) -> None: proc = subprocess.run( - [ - "nix", - "build", - "--impure", - "--print-out-paths", - "--expr", - "let f = builtins.getFlake (toString ./.); in " - f"(f.nixosConfigurations.{machine}.extendModules " - "{ modules = [{ clanCore.clanDir = toString ./.; }]; })" - ".config.system.clan.generateSecrets", - ], - check=True, + nix_build( + [ + f'path:{clan_dir}#nixosConfigurations."{machine}".config.system.clan.generateSecrets' + ] + ), capture_output=True, text=True, ) @@ -30,7 +31,7 @@ def get_secret_script(machine: str) -> None: print(secret_generator_script) secret_generator = subprocess.run( [secret_generator_script], - check=True, + env=env, ) if secret_generator.returncode != 0: @@ -40,7 +41,7 @@ def get_secret_script(machine: str) -> None: def generate_command(args: argparse.Namespace) -> None: - get_secret_script(args.machine) + generate_secrets(args.machine) def register_generate_parser(parser: argparse.ArgumentParser) -> None: diff --git a/pkgs/clan-cli/clan_cli/secrets/upload.py b/pkgs/clan-cli/clan_cli/secrets/upload.py new file mode 100644 index 000000000..38a27b96f --- /dev/null +++ b/pkgs/clan-cli/clan_cli/secrets/upload.py @@ -0,0 +1,60 @@ +import argparse +import json +import subprocess + +from clan_cli.errors import ClanError + +from ..dirs import get_clan_flake_toplevel +from ..nix import nix_build, nix_eval + + +def upload_secrets(machine: str) -> None: + clan_dir = get_clan_flake_toplevel().as_posix() + + proc = subprocess.run( + nix_build( + [ + f'{clan_dir}#nixosConfigurations."{machine}".config.system.clan.uploadSecrets' + ] + ), + stdout=subprocess.PIPE, + text=True, + check=True, + ) + host = json.loads( + subprocess.run( + nix_eval( + [ + f'{clan_dir}#nixosConfigurations."{machine}".config.clan.networking.deploymentAddress' + ] + ), + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + ) + + secret_upload_script = proc.stdout.strip() + secret_upload = subprocess.run( + [ + secret_upload_script, + host, + ], + ) + + if secret_upload.returncode != 0: + raise ClanError("failed to upload secrets") + else: + print("successfully uploaded secrets") + + +def upload_command(args: argparse.Namespace) -> None: + upload_secrets(args.machine) + + +def register_upload_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "machine", + help="The machine to upload secrets to", + ) + parser.set_defaults(func=upload_command)