add secrets integration
clan-cli: also depend on age for secrets
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import admin, ssh
|
from . import admin, secrets, ssh
|
||||||
from .errors import ClanError
|
from .errors import ClanError
|
||||||
|
|
||||||
has_argcomplete = True
|
has_argcomplete = True
|
||||||
@@ -23,6 +23,9 @@ def main() -> None:
|
|||||||
parser_ssh = subparsers.add_parser("ssh", help="ssh to a remote machine")
|
parser_ssh = subparsers.add_parser("ssh", help="ssh to a remote machine")
|
||||||
ssh.register_parser(parser_ssh)
|
ssh.register_parser(parser_ssh)
|
||||||
|
|
||||||
|
parser_secrets = subparsers.add_parser("secrets", help="manage secrets")
|
||||||
|
secrets.register_parser(parser_secrets)
|
||||||
|
|
||||||
if has_argcomplete:
|
if has_argcomplete:
|
||||||
argcomplete.autocomplete(parser)
|
argcomplete.autocomplete(parser)
|
||||||
|
|
||||||
|
|||||||
28
pkgs/clan-cli/clan_cli/secrets/__init__.py
Normal file
28
pkgs/clan-cli/clan_cli/secrets/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# !/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from .groups import register_groups_parser
|
||||||
|
from .machines import register_machines_parser
|
||||||
|
from .secrets import register_secrets_parser
|
||||||
|
from .users import register_users_parser
|
||||||
|
|
||||||
|
|
||||||
|
# takes a (sub)parser and configures it
|
||||||
|
def register_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="command",
|
||||||
|
description="the command to run",
|
||||||
|
help="the command to run",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
groups_parser = subparser.add_parser("groups", help="manage groups")
|
||||||
|
register_groups_parser(groups_parser)
|
||||||
|
|
||||||
|
users_parser = subparser.add_parser("users", help="manage users")
|
||||||
|
register_users_parser(users_parser)
|
||||||
|
|
||||||
|
machines_parser = subparser.add_parser("machines", help="manage machines")
|
||||||
|
register_machines_parser(machines_parser)
|
||||||
|
|
||||||
|
register_secrets_parser(subparser)
|
||||||
71
pkgs/clan-cli/clan_cli/secrets/folders.py
Normal file
71
pkgs/clan-cli/clan_cli/secrets/folders.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..dirs import get_clan_flake_toplevel
|
||||||
|
from ..errors import ClanError
|
||||||
|
|
||||||
|
|
||||||
|
def get_sops_folder() -> Path:
|
||||||
|
return get_clan_flake_toplevel() / "sops"
|
||||||
|
|
||||||
|
|
||||||
|
def gen_sops_subfolder(subdir: str) -> Callable[[], Path]:
|
||||||
|
def folder() -> Path:
|
||||||
|
return get_clan_flake_toplevel() / "sops" / subdir
|
||||||
|
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
sops_secrets_folder = gen_sops_subfolder("secrets")
|
||||||
|
sops_users_folder = gen_sops_subfolder("users")
|
||||||
|
sops_machines_folder = gen_sops_subfolder("machines")
|
||||||
|
sops_groups_folder = gen_sops_subfolder("groups")
|
||||||
|
|
||||||
|
|
||||||
|
def list_objects(path: Path, is_valid: Callable[[str], bool]) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for f in os.listdir(path):
|
||||||
|
if is_valid(f):
|
||||||
|
print(f)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_object(path: Path, name: str) -> None:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path / name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise ClanError(f"{name} not found in {path}")
|
||||||
|
if not os.listdir(path):
|
||||||
|
os.rmdir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def add_key(path: Path, publickey: str, overwrite: bool) -> None:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
||||||
|
if not overwrite:
|
||||||
|
flags |= os.O_EXCL
|
||||||
|
fd = os.open(path / "key.json", flags)
|
||||||
|
except FileExistsError:
|
||||||
|
raise ClanError(f"{path.name} already exists in {path}")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump({"publickey": publickey, "type": "age"}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def read_key(path: Path) -> str:
|
||||||
|
with open(path / "key.json") as f:
|
||||||
|
try:
|
||||||
|
key = json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ClanError(f"Failed to decode {path.name}: {e}")
|
||||||
|
if key["type"] != "age":
|
||||||
|
raise ClanError(
|
||||||
|
f"{path.name} is not an age key but {key['type']}. This is not supported"
|
||||||
|
)
|
||||||
|
publickey = key.get("publickey")
|
||||||
|
if not publickey:
|
||||||
|
raise ClanError(f"{path.name} does not contain a public key")
|
||||||
|
return publickey
|
||||||
177
pkgs/clan-cli/clan_cli/secrets/groups.py
Normal file
177
pkgs/clan-cli/clan_cli/secrets/groups.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..errors import ClanError
|
||||||
|
from . import secrets
|
||||||
|
from .folders import sops_groups_folder, sops_machines_folder, sops_users_folder
|
||||||
|
from .types import (
|
||||||
|
VALID_USER_NAME,
|
||||||
|
group_name_type,
|
||||||
|
machine_name_type,
|
||||||
|
secret_name_type,
|
||||||
|
user_name_type,
|
||||||
|
validate_hostname,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def machines_folder(group: str) -> Path:
|
||||||
|
return sops_groups_folder() / group / "machines"
|
||||||
|
|
||||||
|
|
||||||
|
def users_folder(group: str) -> Path:
|
||||||
|
return sops_groups_folder() / group / "users"
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make this a tree
|
||||||
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
|
folder = sops_groups_folder()
|
||||||
|
if not folder.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for group in os.listdir(folder):
|
||||||
|
group_folder = folder / group
|
||||||
|
if not group_folder.is_dir():
|
||||||
|
continue
|
||||||
|
print(group)
|
||||||
|
machines = machines_folder(group)
|
||||||
|
if machines.is_dir():
|
||||||
|
print("machines:")
|
||||||
|
for f in machines.iterdir():
|
||||||
|
if validate_hostname(f.name):
|
||||||
|
print(f.name)
|
||||||
|
users = users_folder(group)
|
||||||
|
if users.is_dir():
|
||||||
|
print("users:")
|
||||||
|
for f in users.iterdir():
|
||||||
|
if VALID_USER_NAME.match(f.name):
|
||||||
|
print(f)
|
||||||
|
|
||||||
|
|
||||||
|
def add_member(group_folder: Path, source_folder: Path, name: str) -> None:
|
||||||
|
source = source_folder / name
|
||||||
|
if not source.exists():
|
||||||
|
raise ClanError(f"{name} does not exist in {source_folder}")
|
||||||
|
group_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
user_target = group_folder / name
|
||||||
|
if user_target.exists():
|
||||||
|
if not user_target.is_symlink():
|
||||||
|
raise ClanError(
|
||||||
|
f"Cannot add user {name}. {user_target} exists but is not a symlink"
|
||||||
|
)
|
||||||
|
os.remove(user_target)
|
||||||
|
user_target.symlink_to(source)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_member(group_folder: Path, name: str) -> None:
|
||||||
|
target = group_folder / name
|
||||||
|
if not target.exists():
|
||||||
|
raise ClanError(f"{name} does not exist in group in {group_folder}")
|
||||||
|
os.remove(target)
|
||||||
|
|
||||||
|
if len(os.listdir(group_folder)) == 0:
|
||||||
|
os.rmdir(group_folder)
|
||||||
|
|
||||||
|
if len(os.listdir(group_folder.parent)) == 0:
|
||||||
|
os.rmdir(group_folder.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def add_user_command(args: argparse.Namespace) -> None:
|
||||||
|
add_member(users_folder(args.group), sops_users_folder(), args.user)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_user_command(args: argparse.Namespace) -> None:
|
||||||
|
remove_member(users_folder(args.group), args.user)
|
||||||
|
|
||||||
|
|
||||||
|
def add_machine_command(args: argparse.Namespace) -> None:
|
||||||
|
add_member(
|
||||||
|
machines_folder(args.group),
|
||||||
|
sops_machines_folder(),
|
||||||
|
args.machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_machine_command(args: argparse.Namespace) -> None:
|
||||||
|
remove_member(machines_folder(args.group), args.machine)
|
||||||
|
|
||||||
|
|
||||||
|
def add_group_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("group", help="the name of the secret", type=group_name_type)
|
||||||
|
|
||||||
|
|
||||||
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.allow_member(
|
||||||
|
secrets.groups_folder(args.group), sops_machines_folder(), args.group
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.disallow_member(secrets.groups_folder(args.group), args.group)
|
||||||
|
|
||||||
|
|
||||||
|
def register_groups_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="command",
|
||||||
|
description="the command to run",
|
||||||
|
help="the command to run",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
list_parser = subparser.add_parser("list", help="list groups")
|
||||||
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
add_machine_parser = subparser.add_parser(
|
||||||
|
"add-machine", help="add a machine to group"
|
||||||
|
)
|
||||||
|
add_group_argument(add_machine_parser)
|
||||||
|
add_machine_parser.add_argument(
|
||||||
|
"machine", help="the name of the machines to add", type=machine_name_type
|
||||||
|
)
|
||||||
|
add_machine_parser.set_defaults(func=add_machine_command)
|
||||||
|
|
||||||
|
remove_machine_parser = subparser.add_parser(
|
||||||
|
"remove-machine", help="remove a machine from group"
|
||||||
|
)
|
||||||
|
add_group_argument(remove_machine_parser)
|
||||||
|
remove_machine_parser.add_argument(
|
||||||
|
"machine", help="the name of the machines to remove", type=machine_name_type
|
||||||
|
)
|
||||||
|
remove_machine_parser.set_defaults(func=remove_machine_command)
|
||||||
|
|
||||||
|
add_user_parser = subparser.add_parser("add-user", help="add a user to group")
|
||||||
|
add_group_argument(add_user_parser)
|
||||||
|
add_user_parser.add_argument(
|
||||||
|
"user", help="the name of the user to add", type=user_name_type
|
||||||
|
)
|
||||||
|
add_user_parser.set_defaults(func=add_user_command)
|
||||||
|
|
||||||
|
remove_user_parser = subparser.add_parser(
|
||||||
|
"remove-user", help="remove a user from group"
|
||||||
|
)
|
||||||
|
add_group_argument(remove_user_parser)
|
||||||
|
remove_user_parser.add_argument(
|
||||||
|
"user", help="the name of the user to remove", type=user_name_type
|
||||||
|
)
|
||||||
|
remove_user_parser.set_defaults(func=remove_user_command)
|
||||||
|
|
||||||
|
add_secret_parser = subparser.add_parser(
|
||||||
|
"add-secret", help="allow a user to access a secret"
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"group", help="the name of the user", type=group_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.set_defaults(func=add_secret_command)
|
||||||
|
|
||||||
|
remove_secret_parser = subparser.add_parser(
|
||||||
|
"remove-secret", help="remove a group's access to a secret"
|
||||||
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"group", help="the name of the group", type=group_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.set_defaults(func=remove_secret_command)
|
||||||
89
pkgs/clan-cli/clan_cli/secrets/machines.py
Normal file
89
pkgs/clan-cli/clan_cli/secrets/machines.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
from . import secrets
|
||||||
|
from .folders import add_key, list_objects, remove_object, sops_machines_folder
|
||||||
|
from .types import (
|
||||||
|
machine_name_type,
|
||||||
|
public_or_private_age_key_type,
|
||||||
|
secret_name_type,
|
||||||
|
validate_hostname,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
|
list_objects(sops_machines_folder(), lambda x: validate_hostname(x))
|
||||||
|
|
||||||
|
|
||||||
|
def add_command(args: argparse.Namespace) -> None:
|
||||||
|
add_key(sops_machines_folder() / args.machine, args.key, args.force)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
|
remove_object(sops_machines_folder(), args.machine)
|
||||||
|
|
||||||
|
|
||||||
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.allow_member(
|
||||||
|
secrets.machines_folder(args.group), sops_machines_folder(), args.machine
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.disallow_member(secrets.machines_folder(args.group), args.machine)
|
||||||
|
|
||||||
|
|
||||||
|
def register_machines_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="command",
|
||||||
|
description="the command to run",
|
||||||
|
help="the command to run",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
list_parser = subparser.add_parser("list", help="list machines")
|
||||||
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
add_parser = subparser.add_parser("add", help="add a machine")
|
||||||
|
add_parser.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--force",
|
||||||
|
help="overwrite existing machine",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
add_parser.add_argument(
|
||||||
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
|
)
|
||||||
|
add_parser.add_argument(
|
||||||
|
"key",
|
||||||
|
help="public key or private key of the user",
|
||||||
|
type=public_or_private_age_key_type,
|
||||||
|
)
|
||||||
|
add_parser.set_defaults(func=add_command)
|
||||||
|
|
||||||
|
remove_parser = subparser.add_parser("remove", help="remove a machine")
|
||||||
|
remove_parser.add_argument(
|
||||||
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
|
)
|
||||||
|
remove_parser.set_defaults(func=remove_command)
|
||||||
|
|
||||||
|
add_secret_parser = subparser.add_parser(
|
||||||
|
"add-secret", help="allow a machine to access a secret"
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"machine", help="the name of the machine", type=machine_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.set_defaults(func=add_secret_command)
|
||||||
|
|
||||||
|
remove_secret_parser = subparser.add_parser(
|
||||||
|
"remove-secret", help="remove a group's access to a secret"
|
||||||
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"machine", help="the name of the group", type=machine_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.set_defaults(func=remove_secret_command)
|
||||||
124
pkgs/clan-cli/clan_cli/secrets/secrets.py
Normal file
124
pkgs/clan-cli/clan_cli/secrets/secrets.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from .. import tty
|
||||||
|
from ..errors import ClanError
|
||||||
|
from ..nix import nix_shell
|
||||||
|
from .folders import list_objects, sops_secrets_folder
|
||||||
|
from .sops import SopsKey, encrypt_file, ensure_sops_key, read_key
|
||||||
|
from .types import VALID_SECRET_NAME, secret_name_type
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
|
list_objects(
|
||||||
|
sops_secrets_folder(), lambda n: VALID_SECRET_NAME.match(n) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_command(args: argparse.Namespace) -> None:
|
||||||
|
secret: str = args.secret
|
||||||
|
ensure_sops_key()
|
||||||
|
secret_path = sops_secrets_folder() / secret / "secret"
|
||||||
|
if not secret_path.exists():
|
||||||
|
raise ClanError(f"Secret '{secret}' does not exist")
|
||||||
|
cmd = nix_shell(["sops"], ["sops", "--decrypt", str(secret_path)])
|
||||||
|
res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, text=True)
|
||||||
|
print(res.stdout, end="")
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(key: SopsKey, secret: Path, value: IO[str]) -> None:
|
||||||
|
keys = set([key.pubkey])
|
||||||
|
for kind in ["users", "machines", "groups"]:
|
||||||
|
if not (sops_secrets_folder() / kind).is_dir():
|
||||||
|
continue
|
||||||
|
k = read_key(sops_secrets_folder() / kind)
|
||||||
|
keys.add(k)
|
||||||
|
encrypt_file(secret / "secret", value, list(sorted(keys)))
|
||||||
|
|
||||||
|
|
||||||
|
def set_command(args: argparse.Namespace) -> None:
|
||||||
|
secret: str = args.secret
|
||||||
|
key = ensure_sops_key()
|
||||||
|
secret_value = os.environ.get("SOPS_NIX_SECRET")
|
||||||
|
if secret_value:
|
||||||
|
encrypt_secret(key, sops_secrets_folder() / secret, StringIO(secret_value))
|
||||||
|
elif tty.is_interactive():
|
||||||
|
secret = getpass.getpass(prompt="Paste your secret: ")
|
||||||
|
encrypt_secret(key, sops_secrets_folder() / secret, StringIO(secret))
|
||||||
|
else:
|
||||||
|
encrypt_secret(key, sops_secrets_folder() / secret, sys.stdin)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
|
secret: str = args.secret
|
||||||
|
path = sops_secrets_folder() / secret
|
||||||
|
if not path.exists():
|
||||||
|
raise ClanError(f"Secret '{secret}' does not exist")
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def add_secret_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("secret", help="the name of the secret", type=secret_name_type)
|
||||||
|
|
||||||
|
|
||||||
|
def allow_member(group_folder: Path, source_folder: Path, name: str) -> None:
|
||||||
|
source = source_folder / name
|
||||||
|
if not source.exists():
|
||||||
|
raise ClanError(f"{name} does not exist in {source_folder}")
|
||||||
|
group_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
user_target = group_folder / name
|
||||||
|
if user_target.exists():
|
||||||
|
if not user_target.is_symlink():
|
||||||
|
raise ClanError(
|
||||||
|
f"Cannot add user {name}. {user_target} exists but is not a symlink"
|
||||||
|
)
|
||||||
|
os.remove(user_target)
|
||||||
|
user_target.symlink_to(source)
|
||||||
|
|
||||||
|
|
||||||
|
def disallow_member(group_folder: Path, name: str) -> None:
|
||||||
|
target = group_folder / name
|
||||||
|
if not target.exists():
|
||||||
|
raise ClanError(f"{name} does not exist in group in {group_folder}")
|
||||||
|
os.remove(target)
|
||||||
|
|
||||||
|
if len(os.listdir(group_folder)) == 0:
|
||||||
|
os.rmdir(group_folder)
|
||||||
|
|
||||||
|
if len(os.listdir(group_folder.parent)) == 0:
|
||||||
|
os.rmdir(group_folder.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def machines_folder(group: str) -> Path:
|
||||||
|
return sops_secrets_folder() / group / "machines"
|
||||||
|
|
||||||
|
|
||||||
|
def users_folder(group: str) -> Path:
|
||||||
|
return sops_secrets_folder() / group / "users"
|
||||||
|
|
||||||
|
|
||||||
|
def groups_folder(group: str) -> Path:
|
||||||
|
return sops_secrets_folder() / group / "groups"
|
||||||
|
|
||||||
|
|
||||||
|
def register_secrets_parser(subparser: argparse._SubParsersAction) -> None:
|
||||||
|
parser_list = subparser.add_parser("list", help="list secrets")
|
||||||
|
parser_list.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
parser_get = subparser.add_parser("get", help="get a secret")
|
||||||
|
add_secret_argument(parser_get)
|
||||||
|
parser_get.set_defaults(func=get_command)
|
||||||
|
|
||||||
|
parser_set = subparser.add_parser("set", help="set a secret")
|
||||||
|
add_secret_argument(parser_set)
|
||||||
|
parser_set.set_defaults(func=set_command)
|
||||||
|
|
||||||
|
parser_delete = subparser.add_parser("remove", help="remove a secret")
|
||||||
|
add_secret_argument(parser_delete)
|
||||||
|
parser_delete.set_defaults(func=remove_command)
|
||||||
124
pkgs/clan-cli/clan_cli/secrets/sops.py
Normal file
124
pkgs/clan-cli/clan_cli/secrets/sops.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from .. import tty
|
||||||
|
from ..dirs import user_config_dir
|
||||||
|
from ..nix import nix_shell
|
||||||
|
from .folders import add_key, read_key, sops_users_folder
|
||||||
|
|
||||||
|
|
||||||
|
class SopsKey:
|
||||||
|
def __init__(self, pubkey: str) -> None:
|
||||||
|
self.pubkey = pubkey
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_key(privkey: str) -> str:
|
||||||
|
cmd = nix_shell(["age"], ["age-keygen", "-y"])
|
||||||
|
res = subprocess.run(
|
||||||
|
cmd, input=privkey, check=True, stdout=subprocess.PIPE, text=True
|
||||||
|
)
|
||||||
|
return res.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_user(users_folder: Path, user: str) -> str:
|
||||||
|
"""Return a unique path in the users_folder for the given user."""
|
||||||
|
i = 0
|
||||||
|
path = users_folder / user
|
||||||
|
while path.exists():
|
||||||
|
i += 1
|
||||||
|
user = user + str(i)
|
||||||
|
path = users_folder / user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_name(user: str) -> str:
|
||||||
|
"""Ask the user for their name until a unique one is provided."""
|
||||||
|
while True:
|
||||||
|
name = input(
|
||||||
|
f"Enter your user name for which the key will be stored as [{user}]: "
|
||||||
|
)
|
||||||
|
if name:
|
||||||
|
user = name
|
||||||
|
if not (sops_users_folder() / user).exists():
|
||||||
|
return user
|
||||||
|
print(f"{sops_users_folder() / user} already exists")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user(pub_key: str) -> SopsKey:
|
||||||
|
key = SopsKey(pub_key)
|
||||||
|
users_folder = sops_users_folder()
|
||||||
|
|
||||||
|
# Check if the public key already exists for any user
|
||||||
|
if users_folder.exists():
|
||||||
|
for user in users_folder.iterdir():
|
||||||
|
if not user.is_dir():
|
||||||
|
continue
|
||||||
|
if read_key(user) == pub_key:
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Find a unique user name if the public key is not found
|
||||||
|
try:
|
||||||
|
loginname = os.getlogin()
|
||||||
|
except OSError:
|
||||||
|
loginname = os.environ.get("USER", "nobody")
|
||||||
|
username = get_unique_user(users_folder, loginname)
|
||||||
|
|
||||||
|
if tty.is_interactive():
|
||||||
|
# Ask the user for their name until a unique one is provided
|
||||||
|
username = get_user_name(username)
|
||||||
|
|
||||||
|
# Add the public key for the user
|
||||||
|
add_key(users_folder / username, pub_key, False)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_sops_key() -> SopsKey:
|
||||||
|
key = os.environ.get("SOPS_AGE_KEY")
|
||||||
|
if key:
|
||||||
|
return ensure_user(get_public_key(key))
|
||||||
|
raw_path = os.environ.get("SOPS_AGE_KEY_FILE")
|
||||||
|
if raw_path:
|
||||||
|
path = Path(raw_path)
|
||||||
|
else:
|
||||||
|
path = user_config_dir() / "sops" / "age" / "keys.txt"
|
||||||
|
if path.exists():
|
||||||
|
return ensure_user(get_public_key(path.read_text()))
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = nix_shell(["age"], ["age-keygen", "-o", str(path)])
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
tty.info(
|
||||||
|
f"Generated age key at '{path}'. Please back it up on a secure location or you will lose access to your secrets."
|
||||||
|
)
|
||||||
|
return ensure_user(get_public_key(path.read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_file(secret_path: Path, content: IO[str], keys: list[str]) -> None:
|
||||||
|
folder = secret_path.parent
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# hopefully /tmp is written to an in-memory file to avoid leaking secrets
|
||||||
|
with NamedTemporaryFile(delete=False) as f:
|
||||||
|
try:
|
||||||
|
with open(f.name, "w") as fd:
|
||||||
|
shutil.copyfileobj(content, fd)
|
||||||
|
args = ["sops"]
|
||||||
|
for key in keys:
|
||||||
|
args.extend(["--age", key])
|
||||||
|
args.extend(["-i", "--encrypt", str(f.name)])
|
||||||
|
cmd = nix_shell(["sops"], args)
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
# atomic copy of the encrypted file
|
||||||
|
with NamedTemporaryFile(dir=folder, delete=False) as f2:
|
||||||
|
shutil.copyfile(f.name, f2.name)
|
||||||
|
os.rename(f2.name, secret_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(f.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
71
pkgs/clan-cli/clan_cli/secrets/types.py
Normal file
71
pkgs/clan-cli/clan_cli/secrets/types.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..errors import ClanError
|
||||||
|
from .sops import get_public_key
|
||||||
|
|
||||||
|
VALID_SECRET_NAME = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||||
|
VALID_USER_NAME = re.compile(r"^[a-z_]([a-z0-9_-]{0,31})?$")
|
||||||
|
VALID_HOSTNAME = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_hostname(hostname: str) -> bool:
|
||||||
|
if len(hostname) > 63:
|
||||||
|
return False
|
||||||
|
return VALID_HOSTNAME.match(hostname) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def secret_name_type(arg_value: str) -> str:
|
||||||
|
if not VALID_SECRET_NAME.match(arg_value):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"Invalid character in secret name. Allowed characters are a-z, A-Z, 0-9, ., -, and _"
|
||||||
|
)
|
||||||
|
return arg_value
|
||||||
|
|
||||||
|
|
||||||
|
def machine_name_type(arg_value: str) -> str:
|
||||||
|
if len(arg_value) > 63:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"Machine name must be less than 63 characters long"
|
||||||
|
)
|
||||||
|
if not VALID_SECRET_NAME.match(arg_value):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"Invalid character in machine name. Allowed characters are a-z, 0-9, ., -, and _. Must not start with a number"
|
||||||
|
)
|
||||||
|
return arg_value
|
||||||
|
|
||||||
|
|
||||||
|
def public_or_private_age_key_type(arg_value: str) -> str:
|
||||||
|
if os.path.isfile(arg_value):
|
||||||
|
arg_value = Path(arg_value).read_text().strip()
|
||||||
|
if arg_value.startswith("age1"):
|
||||||
|
return arg_value.strip()
|
||||||
|
if arg_value.startswith("AGE-SECRET-KEY-"):
|
||||||
|
return get_public_key(arg_value)
|
||||||
|
if not arg_value.startswith("age1"):
|
||||||
|
raise ClanError(
|
||||||
|
f"Please provide an age key starting with age1, got: '{arg_value}'"
|
||||||
|
)
|
||||||
|
return arg_value
|
||||||
|
|
||||||
|
|
||||||
|
def group_or_user_name_type(what: str) -> Callable[[str], str]:
|
||||||
|
def name_type(arg_value: str) -> str:
|
||||||
|
if len(arg_value) > 32:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"{what.capitalize()} name must be less than 32 characters long"
|
||||||
|
)
|
||||||
|
if not VALID_USER_NAME.match(arg_value):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"Invalid character in {what} name. Allowed characters are a-z, 0-9, -, and _. Must start with a letter or _"
|
||||||
|
)
|
||||||
|
return arg_value
|
||||||
|
|
||||||
|
return name_type
|
||||||
|
|
||||||
|
|
||||||
|
user_name_type = group_or_user_name_type("user")
|
||||||
|
group_name_type = group_or_user_name_type("group")
|
||||||
96
pkgs/clan-cli/clan_cli/secrets/users.py
Normal file
96
pkgs/clan-cli/clan_cli/secrets/users.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
from . import secrets
|
||||||
|
from .folders import add_key, list_objects, remove_object, sops_users_folder
|
||||||
|
from .types import (
|
||||||
|
VALID_SECRET_NAME,
|
||||||
|
public_or_private_age_key_type,
|
||||||
|
secret_name_type,
|
||||||
|
user_name_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_user(name: str, key: str, force: bool) -> None:
|
||||||
|
add_key(sops_users_folder() / name, key, force)
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(args: argparse.Namespace) -> None:
|
||||||
|
list_objects(sops_users_folder(), lambda n: VALID_SECRET_NAME.match(n) is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def add_command(args: argparse.Namespace) -> None:
|
||||||
|
add_user(args.user, args.key, args.force)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_command(args: argparse.Namespace) -> None:
|
||||||
|
remove_object(sops_users_folder(), args.user)
|
||||||
|
|
||||||
|
|
||||||
|
def add_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.allow_member(
|
||||||
|
secrets.groups_folder(args.group), sops_users_folder(), args.group
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_secret_command(args: argparse.Namespace) -> None:
|
||||||
|
secrets.disallow_member(secrets.groups_folder(args.group), args.group)
|
||||||
|
|
||||||
|
|
||||||
|
def register_users_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
title="command",
|
||||||
|
description="the command to run",
|
||||||
|
help="the command to run",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
list_parser = subparser.add_parser("list", help="list users")
|
||||||
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
add_parser = subparser.add_parser("add", help="add a user")
|
||||||
|
add_parser.add_argument(
|
||||||
|
"-f", "--force", help="overwrite existing user", action="store_true"
|
||||||
|
)
|
||||||
|
add_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
||||||
|
add_parser.add_argument(
|
||||||
|
"key",
|
||||||
|
help="public key or private key of the user",
|
||||||
|
type=public_or_private_age_key_type,
|
||||||
|
)
|
||||||
|
add_parser.set_defaults(func=add_command)
|
||||||
|
|
||||||
|
remove_parser = subparser.add_parser("remove", help="remove a user")
|
||||||
|
remove_parser.add_argument("user", help="the name of the user", type=user_name_type)
|
||||||
|
remove_parser.set_defaults(func=remove_command)
|
||||||
|
|
||||||
|
add_secret_parser = subparser.add_parser(
|
||||||
|
"add-secret", help="allow a user to access a secret"
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"user", help="the name of the group", type=user_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.set_defaults(func=add_secret_command)
|
||||||
|
|
||||||
|
add_secret_parser = subparser.add_parser(
|
||||||
|
"add-secret", help="allow a machine to access a secret"
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"user", help="the name of the group", type=user_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
add_secret_parser.set_defaults(func=add_secret_command)
|
||||||
|
|
||||||
|
remove_secret_parser = subparser.add_parser(
|
||||||
|
"remove-secret", help="remove a user's access to a secret"
|
||||||
|
)
|
||||||
|
add_secret_parser.add_argument(
|
||||||
|
"user", help="the name of the group", type=user_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.add_argument(
|
||||||
|
"secret", help="the name of the secret", type=secret_name_type
|
||||||
|
)
|
||||||
|
remove_secret_parser.set_defaults(func=remove_secret_command)
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
, installShellFiles
|
, installShellFiles
|
||||||
, zerotierone
|
, zerotierone
|
||||||
, bubblewrap
|
, bubblewrap
|
||||||
|
, sops
|
||||||
|
, age
|
||||||
, self
|
, self
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -71,7 +73,7 @@ let
|
|||||||
|
|
||||||
clan-pytest = runCommand "${name}-tests"
|
clan-pytest = runCommand "${name}-tests"
|
||||||
{
|
{
|
||||||
nativeBuildInputs = [ zerotierone bubblewrap ];
|
nativeBuildInputs = [ zerotierone bubblewrap sops age ];
|
||||||
} ''
|
} ''
|
||||||
cp -r ${src} ./src
|
cp -r ${src} ./src
|
||||||
chmod +w -R ./src
|
chmod +w -R ./src
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
openssh
|
openssh
|
||||||
sshpass
|
sshpass
|
||||||
zbar
|
zbar
|
||||||
tor;
|
tor
|
||||||
|
sops
|
||||||
|
age;
|
||||||
# Override license so that we can build zerotierone without
|
# Override license so that we can build zerotierone without
|
||||||
# having to re-import nixpkgs.
|
# having to re-import nixpkgs.
|
||||||
zerotierone = pkgs.zerotierone.overrideAttrs (_old: { meta = { }; });
|
zerotierone = pkgs.zerotierone.overrideAttrs (_old: { meta = { }; });
|
||||||
|
|||||||
120
pkgs/clan-cli/tests/test_secrets.py
Normal file
120
pkgs/clan-cli/tests/test_secrets.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from environment import mock_env
|
||||||
|
|
||||||
|
from clan_cli.errors import ClanError
|
||||||
|
from clan_cli.secrets import register_parser
|
||||||
|
|
||||||
|
|
||||||
|
class SecretCli:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.parser = argparse.ArgumentParser()
|
||||||
|
register_parser(self.parser)
|
||||||
|
|
||||||
|
def run(self, args: list[str]) -> argparse.Namespace:
|
||||||
|
parsed = self.parser.parse_args(args)
|
||||||
|
parsed.func(parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
PUBKEY = "age1dhwqzkah943xzc34tc3dlmfayyevcmdmxzjezdgdy33euxwf59vsp3vk3c"
|
||||||
|
PRIVKEY = "AGE-SECRET-KEY-1KF8E3SR3TTGL6M476SKF7EEMR4H9NF7ZWYSLJUAK8JX276JC7KUSSURKFK"
|
||||||
|
|
||||||
|
|
||||||
|
def _test_identities(
|
||||||
|
what: str, clan_flake: Path, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
cli = SecretCli()
|
||||||
|
sops_folder = clan_flake / "sops"
|
||||||
|
|
||||||
|
cli.run([what, "add", "foo", PUBKEY])
|
||||||
|
assert (sops_folder / what / "foo" / "key.json").exists()
|
||||||
|
with pytest.raises(ClanError):
|
||||||
|
cli.run([what, "add", "foo", PUBKEY])
|
||||||
|
|
||||||
|
cli.run(
|
||||||
|
[
|
||||||
|
what,
|
||||||
|
"add",
|
||||||
|
"-f",
|
||||||
|
"foo",
|
||||||
|
PRIVKEY,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
capsys.readouterr() # empty the buffer
|
||||||
|
|
||||||
|
cli.run([what, "list"])
|
||||||
|
out = capsys.readouterr() # empty the buffer
|
||||||
|
assert "foo" in out.out
|
||||||
|
|
||||||
|
cli.run([what, "remove", "foo"])
|
||||||
|
assert not (sops_folder / what / "foo" / "key.json").exists()
|
||||||
|
|
||||||
|
with pytest.raises(ClanError): # already removed
|
||||||
|
cli.run([what, "remove", "foo"])
|
||||||
|
|
||||||
|
capsys.readouterr()
|
||||||
|
cli.run([what, "list"])
|
||||||
|
out = capsys.readouterr()
|
||||||
|
assert "foo" not in out.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_users(clan_flake: Path, capsys: pytest.CaptureFixture) -> None:
|
||||||
|
_test_identities("users", clan_flake, capsys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_machines(clan_flake: Path, capsys: pytest.CaptureFixture) -> None:
|
||||||
|
_test_identities("machines", clan_flake, capsys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups(clan_flake: Path, capsys: pytest.CaptureFixture) -> None:
|
||||||
|
cli = SecretCli()
|
||||||
|
capsys.readouterr() # empty the buffer
|
||||||
|
cli.run(["groups", "list"])
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
with pytest.raises(ClanError): # machine does not exist yet
|
||||||
|
cli.run(["groups", "add-machine", "group1", "machine1"])
|
||||||
|
with pytest.raises(ClanError): # user does not exist yet
|
||||||
|
cli.run(["groups", "add-user", "groupb1", "user1"])
|
||||||
|
cli.run(["machines", "add", "machine1", PUBKEY])
|
||||||
|
cli.run(["groups", "add-machine", "group1", "machine1"])
|
||||||
|
|
||||||
|
# Should this fail?
|
||||||
|
cli.run(["groups", "add-machine", "group1", "machine1"])
|
||||||
|
|
||||||
|
cli.run(["users", "add", "user1", PUBKEY])
|
||||||
|
cli.run(["groups", "add-user", "group1", "user1"])
|
||||||
|
|
||||||
|
capsys.readouterr() # empty the buffer
|
||||||
|
cli.run(["groups", "list"])
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "user1" in out
|
||||||
|
assert "machine1" in out
|
||||||
|
|
||||||
|
cli.run(["groups", "remove-user", "group1", "user1"])
|
||||||
|
cli.run(["groups", "remove-machine", "group1", "machine1"])
|
||||||
|
groups = os.listdir(clan_flake / "sops" / "groups")
|
||||||
|
assert len(groups) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_secrets(
|
||||||
|
clan_flake: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
cli = SecretCli()
|
||||||
|
capsys.readouterr() # empty the buffer
|
||||||
|
cli.run(["list"])
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
with pytest.raises(ClanError): # does not exist yet
|
||||||
|
cli.run(["get", "nonexisting"])
|
||||||
|
with mock_env(
|
||||||
|
SOPS_NIX_SECRET="foo", SOPS_AGE_KEY_FILE=str(clan_flake / ".." / "age.key")
|
||||||
|
):
|
||||||
|
cli.run(["set", "nonexisting"])
|
||||||
|
capsys.readouterr()
|
||||||
|
cli.run(["get", "nonexisting"])
|
||||||
|
assert capsys.readouterr().out == "foo"
|
||||||
Reference in New Issue
Block a user