commit ef8a95cca18daf8c6df7f243736214134432b723 Author: Anton Nesterov Date: Wed Apr 24 22:34:31 2024 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eba74f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv/ \ No newline at end of file diff --git a/.llaves b/.llaves new file mode 100644 index 0000000..3759099 --- /dev/null +++ b/.llaves @@ -0,0 +1 @@ +q q q q \ No newline at end of file diff --git a/.llaves.bin b/.llaves.bin new file mode 100644 index 0000000..e6151c3 Binary files /dev/null and b/.llaves.bin differ diff --git a/.llaves.yaml b/.llaves.yaml new file mode 100644 index 0000000..35b38df --- /dev/null +++ b/.llaves.yaml @@ -0,0 +1,6 @@ +# This file is to be gitignored +# Add your project secrets here +password: + data: "password123" + labels: + label1: "example" \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9c9cc31 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +install: + @echo "Installing..." + @python3 -m pip install -r requirements.txt --break-system-packages + @sudo ./tools/llaves.py link \ No newline at end of file diff --git a/entrega.yaml b/entrega.yaml new file mode 100644 index 0000000..cac5e8a --- /dev/null +++ b/entrega.yaml @@ -0,0 +1,8 @@ +example: + environ: + DOCKER_DEFAULT_PLATFORM: linux/amd64 + steps: + - echo 1 + - echo 2 + - echo 3 + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d3974bf --- /dev/null +++ b/readme.md @@ -0,0 +1,14 @@ +# llaves + +Simple tools for encrypting secrets and running setup tasks. +Mostly used in order to push secrets to docker swarm or manually deploy docker-compose. + +Config files: + +- `.llaves` - to be gitignored, text passphrase +- `.llaves.yaml` - to be gitignored, secrets +- `entrega.yaml` - run tasks with decrypted data + +Using variables: + +Secrets are converted to the env variables using following template `PRIVATE__{secretName}` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f4fd70 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +certifi==2024.2.2 +charset-normalizer==3.3.2 +docker==7.0.0 +idna==3.7 +packaging==24.0 +pycryptodome==3.20.0 +python-dotenv==1.0.1 +PyYAML==6.0.1 +requests==2.31.0 +urllib3==2.2.1 diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/__pycache__/llaves.cpython-312.pyc b/tools/__pycache__/llaves.cpython-312.pyc new file mode 100644 index 0000000..04f4ecd Binary files /dev/null and b/tools/__pycache__/llaves.cpython-312.pyc differ diff --git a/tools/entrega.py b/tools/entrega.py new file mode 100755 index 0000000..c0729ba --- /dev/null +++ b/tools/entrega.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import os +import yaml +import dotenv +dotenv.load_dotenv() + +from llaves import run, LLAVES_PWD + +CURRENT_DIR = os.environ.get('LLAVES_DIR', os.getcwd()) +RUN_FILE = os.environ.get('RUN_FILE', 'entrega.yaml') + + +def main(tasks: list, passphrase: str): + yaml_file = os.path.join(CURRENT_DIR, RUN_FILE) + if not os.path.exists(yaml_file): + print('No file found') + return + + with open(yaml_file, 'rb') as f: + data = yaml.load(f.read(), Loader=yaml.FullLoader) + f.close() + + def fmt_cmd(cmd: str): + return list(filter(lambda x: bool(x), map(lambda x: x.strip(), step.strip().split(' ')))) + + for task in tasks: + if task not in data: + print(f'Task {task} not found') + continue + os.environ.update(data[task].get('environ', {})) + + for name, steps in data.items(): + if name.startswith('treads'): + for step in data[task].get(name, []): + cmd = fmt_cmd(step) + run(passphrase, cmd, True) + + for step in data[task].get('steps', []): + cmd = fmt_cmd(step) + run(passphrase, cmd) + + +if __name__ == '__main__': + app = argparse.ArgumentParser(description='Run commands from a yaml file') + app.add_argument('tasks', help='Tasks to run', nargs='+') + app.add_argument('-p', '--passphrase', help='Passphrase to decrypt the yaml file') + args = app.parse_args() + + if os.path.exists(LLAVES_PWD): + with open(LLAVES_PWD, 'r') as f: + passphrase = f.read().strip() + f.close() + else: + passphrase = args.passphrase or input('Enter passphrase: ') + + main(args.tasks, passphrase.strip()) \ No newline at end of file diff --git a/tools/llaves.py b/tools/llaves.py new file mode 100755 index 0000000..fa5f75e --- /dev/null +++ b/tools/llaves.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +import argparse +import os +import docker +import yaml +import subprocess +from Crypto.Cipher import AES +from Crypto.Hash import MD5 + + +LLAVES_DIR = os.environ.get('LLAVES_DIR', os.getcwd()) +LLAVES_FILE = os.environ.get('LLAVES_FILE', '.llaves.yaml') +LLAVES_OUTPUT_FILE = os.environ.get('LLAVES_OUTPUT_FILE', '.llaves.bin') +LLAVES_PWD_FILE = os.environ.get('LLAVES_PWD_FILE', '.llaves') +LLAVES = os.path.join(LLAVES_DIR, LLAVES_FILE) +LLAVES_OUTPUT = os.path.join(LLAVES_DIR, LLAVES_OUTPUT_FILE) +LLAVES_PWD = os.path.join(LLAVES_DIR, LLAVES_PWD_FILE) + + +if not os.path.exists(LLAVES): + with open(LLAVES, 'w') as f: + f.write('# This file is to be gitignored\n') + f.write('# Add your project secrets here\n') + f.write(""" +password: + data: "password123" + labels: + label1: "example" + """) + f.close() + + +def encrypt_yaml(passphrase: str): + key = MD5.new(passphrase.encode()).digest() + if not os.path.exists(LLAVES_FILE): + print('No file found') + return + cipher = AES.new(key, AES.MODE_CFB) + with open(LLAVES_FILE, 'rb') as f: + data = f.read() + f.close() + ed = cipher.encrypt(data) + iv = cipher.iv + with open(LLAVES_OUTPUT, 'wb') as f: + f.write(iv) + f.write(ed) + f.close() + + +def decrypt_yaml(passphrase: str, return_data=False): + key = MD5.new(passphrase.encode()).digest() + if not os.path.exists(LLAVES_OUTPUT): + print('No encrypted files found') + return + with open(LLAVES_OUTPUT, 'rb') as f: + iv = f.read(16) + ed = f.read() + f.close() + cipher = AES.new(key, AES.MODE_CFB, iv) + data = cipher.decrypt(ed) + if return_data: + return str(data.decode("utf-8", errors="ignore")) + with open(LLAVES_FILE, 'w') as f: + f.write(data.decode()) + f.close() + + +def update_swarm(passphrase: str): + client = docker.from_env() + def remove_old(name): + for secret in client.secrets.list(): + if secret.name == name: + secret.remove() + data = decrypt_yaml(passphrase, return_data=True) + secrets = yaml.load(data, Loader=yaml.FullLoader) + for name, attrs in secrets.items(): + remove_old(name) + client.secrets.create( + name=name, + data=attrs['data'], + labels=attrs['labels'] + ) + +def run(passphrase: str, command: list, parallel=False): + data = decrypt_yaml(passphrase, return_data=True) + secrets = yaml.load(data, Loader=yaml.FullLoader) + env_prefix = 'PRIVATE' + env = os.environ.copy() + for name, attrs in secrets.items(): + env[f'{env_prefix}__{name}'] = attrs['data'] + p = subprocess.Popen(command, env=env) + if not parallel: + p.wait() + + +def clean(): + if os.path.exists(LLAVES_FILE): + os.remove(LLAVES_FILE) + + +if __name__ == '__main__': + app = argparse.ArgumentParser( + prog='llaves', + description='Encrypt, decrypt, deploy secrets', + epilog='Anton Nesterov, DEMIURG.IO' + ) + app.add_argument( + 'action', + type=str, + help='Action to perform', + choices=['encrypt', 'decrypt', 'update:swarm', 'run', 'clean', 'link'] + ) + app.add_argument( + '-p', '--passphrase', + type=str, + help='Passphrase to encrypt/decrypt secrets file', + default=None + ) + app.add_argument( + '-d', '--delete', + action='store_true', + help='Delete secrets file after encryption' + ) + + app.add_argument( + '-c', '--command', + action='store', + help='Command to run with secrets', + ) + + args = app.parse_args() + if os.path.exists(LLAVES_PWD): + with open(LLAVES_PWD, 'r') as f: + passphrase = f.read().strip() + f.close() + else: + passphrase = args.passphrase or input('Enter passphrase: ') + + if not passphrase: + print('Passphrase is required') + exit(1) + + if args.action == 'encrypt': + encrypt_yaml(passphrase) + if args.delete: + clean() + + if args.action == 'decrypt': + decrypt_yaml(passphrase) + + if args.action == 'update:swarm': + update_swarm(passphrase) + if args.delete: + clean() + + if args.action == 'run': + run(passphrase, args.command) + + if args.action == 'clean': + pass + + if args.action == 'link': + dr = os.path.dirname(os.path.realpath(__file__)) + llaves = os.path.join(dr, 'llaves.py') + entrega = os.path.join(dr, 'entrega.py') + os.symlink(llaves, '/usr/local/bin/llaves') + os.symlink(entrega, '/usr/local/bin/entrega') +