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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
imports = [
|
||||
./secrets
|
||||
./zerotier.nix
|
||||
./networking.nix
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
# just some example options. Can be removed later
|
||||
./bloatware
|
||||
|
||||
15
nixosModules/clanCore/networking.nix
Normal file
15
nixosModules/clanCore/networking.nix
Normal 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}";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
118
nixosModules/clanCore/secrets/password-store.nix
Normal file
118
nixosModules/clanCore/secrets/password-store.nix
Normal 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}/
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
60
pkgs/clan-cli/clan_cli/secrets/upload.py
Normal file
60
pkgs/clan-cli/clan_cli/secrets/upload.py
Normal 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)
|
||||
Reference in New Issue
Block a user