Merge pull request 'secrets: add password-store & deploy command' (#263) from lassulus-pass-secrets into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/263
This commit is contained in:
Mic92
2023-09-15 12:17:07 +00:00
11 changed files with 294 additions and 66 deletions

View File

@@ -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

View File

@@ -3,6 +3,7 @@
imports = [
./secrets
./zerotier.nix
./networking.nix
inputs.sops-nix.nixosModules.sops
# just some example options. Can be removed later
./bloatware

View File

@@ -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}";
};
};
}

View File

@@ -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
];
}

View File

@@ -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}/
'';
};
}

View File

@@ -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";

View File

@@ -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)

View File

@@ -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]:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)