From ef8a95cca18daf8c6df7f243736214134432b723 Mon Sep 17 00:00:00 2001 From: Anton Nesterov Date: Wed, 24 Apr 2024 22:34:31 +0200 Subject: [PATCH] init --- .gitignore | 1 + .llaves | 1 + .llaves.bin | Bin 0 -> 143 bytes .llaves.yaml | 6 + Makefile | 4 + entrega.yaml | 8 ++ readme.md | 14 ++ requirements.txt | 10 ++ tools/__init__.py | 0 tools/__pycache__/llaves.cpython-312.pyc | Bin 0 -> 8672 bytes tools/entrega.py | 57 ++++++++ tools/llaves.py | 168 +++++++++++++++++++++++ 12 files changed, 269 insertions(+) create mode 100644 .gitignore create mode 100644 .llaves create mode 100644 .llaves.bin create mode 100644 .llaves.yaml create mode 100644 Makefile create mode 100644 entrega.yaml create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 tools/__init__.py create mode 100644 tools/__pycache__/llaves.cpython-312.pyc create mode 100755 tools/entrega.py create mode 100755 tools/llaves.py 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 0000000000000000000000000000000000000000..e6151c38cdb8db40b2ba721a69cffcff79bdf8b9 GIT binary patch literal 143 zcmV;A0C4}6>=KTs?eHt*sql_PmC?gtcVZ@q^3QIw>+Q41uLFx>hTE4x*|R`>guM+x z*>D=2mPeidB(sY3;CMKm-qUJNPPdd{Gk|C(N%(N@LgN7js=H>9Ao>{z|U`U-F*{V>QQQ-?t0w4)+6>@7Uq6mt#a0q(baD|)(?M8UZ*lX|wxn^F?M@s+z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..04f4ecd61bcb2e58473b3c11c502459dc5ac81ae GIT binary patch literal 8672 zcmcgwYitu)maekv<+2^e&Xc^TBs9T!0wKJ`bjX9Dmj(s`?09KB##Kpd9NT-VLWps? z-4asU(-}EY&nQMcE11z}7X;GI&;7ZN9%-cgF#%_UQ=O4^HS?$cg{D`m__gQO!_LDr z%Kn&2eDAG$&OP^0=Y02^^Pf&9Pr!3$(mQ_0LlA$)FXqDi))FBSA{ilkdQJLB z?cL(DXtmX6)oRK|L2Zf9Q;d%xF|RekPH{etBqTBnGi-grHCuZKLZmF1yLLO}iP81M zqBnew9Z01`rZ~>IgK@0L6~|TVV4O|li{o4{PT0PmaQpT?$ssw5<2*YWS5X{SwWD#a z;<)O!jO!}1PR|ok@0G8>_o|gVqTQ4;K=|sUy6}PRqeO=}3N)fThx45r6`MHvx6fkg z31}dR|IBs4yGv>qw}_SZC|{#^+<0@}qkTN>eOBfn*;q5bpfAn^m&kMS}qYMi|{Fc{g!i-_DI#;1kTVg>PS8<^fG62fR&NWR_dO!Qu*< zw{ZXeSJ1D?tb!Hnmb|da8{79{e%ZI~;*chTw7qSY0~-B{x9C@3`;2el43^Qs$;dN+SMv)}K1{!bdH7)vyF*a=aQX7~|o;gzC%Sh$8So7*=NomI~2Fs2lt&ZFG}Wr;#8!))N9FBxmN2Z9I-hW!kLeaO z9-EGess-ItEi*U09)vvx)f$y%R7Q#hW1^%oVrW8=71a`xRVp4(CV_G%Br7t)I<3-i z6pAVc>$b{$cwux0mlKZBi?9)J{Pl?4Mvfh?^ z`I-+ zBBpX4(oq(^$+)3 zzv`fe_p@L1)59m(;70H%Le#(hbs);}*Mg|;ftGTCqSZ9UISejkI81N>sIZ2M00zfw zJSKfR_$WmvD+`+z1%E8<31d~?@&HI-GlOT2Nit<&tOz7?Y?4Gx%5I&XAd+kttNoTo zq(u5@F@QDqeR%`CD{bZwk=+7y@0pZRU49nxJWTl-4DRUM%7ENIz60dA^ND%@Im7rR zQG+{h6ENN(2Q2xUN+=Tav@+gvRAqE;0X!E*LMVo0WCNA+_PpJW!xIQ_XVZ7Jw4pZ_g1iwK7?={t_7aBoD&N0z*^D>YiMyD^;R z+Vb}1)bJ|nS#x<74lSKnJdvAubZ6m2zUpY+b!?eCrh{pJ-nLr@(*q{_QA_qk`xj0P zhBsj<#=h6?BJ{nkECQm2g8z2l;0R~^%C&!_+WI?=9&xk3tENYq*-efbzFn}Dz1jl@ z_ZjmpSIyMzpE7A?9FhX}IB>m+&RY`FR!@sA0=PRxmn~d$&tNX&Nm>+)!P-;KX?seU z7O1&JWn4IOHN(V5kS4hfI6gs!KN6VjsvS55YA=59{RyV?qswM))L=Pz6@X`sD=am3hT z5_LiS7$E6fEC6t!8NSNsfrznWtq+|5KCcaMB^JB|E)zG7VpD!8dK+OBP$@0Sq9Pzf zmWO!icF!br7F;zbtI-n?kiv!TH!3BHY0H2Kqf`)+y z|Kv}gfD>4AR%QJef8N=W8vV{13 zq|OxTT9)P(=f3#lTY{naj6F@IrBx1qVuh;*Ov+YdDqe8S&$#A-yQbi-UAVp2T&Swg z)i3^Foh587|3gqV{v{1V(GggK$_8UoQ-P=$+^BU$hi4Q_eWRg+*^*C-QOKNCDUK?u zk5&Z?O@G2g12%iWWJ&-)iC~U9cY92YhHIY+<9le!W75Iw;abwno}J^8oMF05lKzqq znI!uq@ekIMFm}G0B1(~6xePi01_P#U>}$@Jw8-wH?faY|92II{Four86zCWfDtqzL z*{g$NBf9EZ{C=Do0LAp$HPu9?Fy{|&@jfmv0^vB}b_m5FdI-kivuF_a@$&Rd2;GB{ zEUWZIJ+_z$gcL8YU7TnHGjZ_%3Pd7Oq@=kj55oL$B#npeAe=7MR2dCnb}}yGXv4fj z1>+jEqUk8Q1cWj8$@5Tv{@UxlUs>G^sSnn8N47K5neEH;<@v_c&;|`A=>c7X3mx5$ zuKsE+ZA~ZAkz8ADEH}FBXn)S_S+@euUpUVTuKpQUpR0QqT;V!XgRAx`eCaV6=4afx zm2gyR+8l&M6(6jc`1k5#C_ssegmNBcxwJy*&N2Nn)UY8vQRbf7K0^Qas1GLh(p&u_ zY!av+ipPYud>t_$DwG0IbOri<0zbJO3OH%j_Swv*GoO9@>Bm36wZgWoaeUU6vHiU3 zk6a5J0N1XYGw1hD1wv83AK*~SBqf9qJ-X}`3^xcj;*r>_nd(V23`Uh$R5%Z@tb}5> zyM^JA56@n?bf)+01+Nt%TNIm6?V=rvi9*RIG6A0q>gnLk{afT@_q45}+ z@-hhDi3qME;*CRxz~kDTplOh{IegzlZ{Zn~*|?-qlTsuOa5NbUfi@>BJ@JI2rw8Yn z^rTUu^J1}$XEOZPK4VwrZZvNe?tmM3dOsA{#T*YzM-&h#Cb*s+QHn^41eT*f7R;}p zX(_R1SnJy)LkNt+ahTJB% zO#~pWPt-ur4>3Q!dix+tXhi&p+AZD22roJ6J)&{}Gyy09$pX!X$5^Oi&^c(S&LQlh z$9jjf%!8iNup50p49JsuFz^9pwN6Ni%0LmE5fOIxbY~uWBM4iJ79OZnI0orUM$dI< zaY8XC3b{-@deCA9&CPGZRjwx>Rs$zZaw(hvw6>{l&fH~XX`R`*``d>Lik}<-qxF93YDHEc9C6jEIM+&W$%f6 z<;j$@;B?_=CVM?|J=gg#rp*m2<9HLW7RFcD=2e?BJ-!gk+Zxsh8|}%N>6_)s{uS<2!=3IoBP8jW=>cRWNwHzJm1RF25q6dfDdE+fc3VsUUg1pMNRXNcX}J zOh&pN?sfx~ssqruV(zTc7c*1!qm6`f$P3^&LyunbZ(4(cBBF z`WaPS;3^AF_!rd5^XoQ(u7MTN46LZc;7CU`mScs=>bJ6l3$Dh3vmcx=EP~o5;#R>` zTX1%6qB~qTAk5nvQ^SQ4{x5`A*w$62d!aKI%sU|)X0g>mHf&eJgWCJGU)Dcpx!;oC zTu7WDMvT^qR-hq-#12j&Ni-c z_Vfoi>Vf0FV}*Oq969lvZ7y&Q^L6|=+hi&ic*(g-53b$6_M*AxS#!^$$$ayXyz?l; z*6ymMy2ZMsrp2b*O;A&JZ<@jS+_BiP)VKABy1-k1@9n!# SXZ-_DU#MpPP)A>AW&aN$gqrLC literal 0 HcmV?d00001 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') +