diff --git a/Dockerfile b/Dockerfile index 962a902..85c284e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ COPY --chown=bw-operator:bw-operator src /home/bw-operator USER bw-operator ENTRYPOINT [ "kopf", "run", "--all-namespaces", "--liveness=http://0.0.0.0:8080/healthz" ] -CMD [ "/home/bw-operator/bitwardenCrdOperator.py", "/home/bw-operator/kv.py", "/home/bw-operator/dockerlogin.py" ] +CMD [ "/home/bw-operator/bitwardenCrdOperator.py", "/home/bw-operator/kv.py", "/home/bw-operator/dockerlogin.py", "/home/bw-operator/template.py"] diff --git a/README.md b/README.md index f28e73c..8cd0f09 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,14 @@ Bitwarden CRD Operator is a kubernetes Operator based on [kopf](https://github.com/nolar/kopf/). The goal is to create kubernetes native secret objects from bitwarden. +
+ +
+ > DISCLAIMER: > This project is still very work in progress :) + ## Getting started You will need a `ClientID` and `ClientSecret` ([where to get these](https://bitwarden.com/help/personal-api-key/)) as well as your password. diff --git a/charts/bitwarden-crd-operator/Chart.yaml b/charts/bitwarden-crd-operator/Chart.yaml index 47562a6..51f7e58 100644 --- a/charts/bitwarden-crd-operator/Chart.yaml +++ b/charts/bitwarden-crd-operator/Chart.yaml @@ -4,15 +4,17 @@ description: Deploy the Bitwarden CRD Operator type: application -version: "v0.3.2" +version: "v0.4.0" -appVersion: "0.3.0" +appVersion: "0.4.0" keywords: - operator - bitwarden - vaultwarden +icon: https://lerentis.github.io/bitwarden-crd-operator/logo.png + home: https://lerentis.github.io/bitwarden-crd-operator/ sources: @@ -30,17 +32,22 @@ annotations: url: https://github.com/Lerentis/bitwarden-crd-operator artifacthub.io/crds: | - kind: BitwardenSecret - version: v1beta3 + version: v1beta4 name: bitwarden-secret displayName: Bitwarden Secret description: Management Object to create secrets from bitwarden - kind: RegistryCredential - version: v1beta3 + version: v1beta4 name: registry-credential displayName: Regestry Credentials description: Management Object to create regestry secrets from bitwarden + - kind: BitwardenTemplate + version: v1beta1 + name: bitwarden-template + displayName: Bitwarden Template + description: Management Object to create secrets from a jinja template with a bitwarden lookup artifacthub.io/crdsExamples: | - - apiVersion: lerentis.uploadfilter24.eu/v1beta3 + - apiVersion: lerentis.uploadfilter24.eu/v1beta4 kind: BitwardenSecret metadata: name: test @@ -55,7 +62,7 @@ annotations: id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" name: "test-secret" namespace: "default" - - apiVersion: lerentis.uploadfilter24.eu/v1beta3 + - apiVersion: lerentis.uploadfilter24.eu/v1beta4 kind: RegistryCredential metadata: name: test @@ -66,13 +73,35 @@ annotations: id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" name: "test-regcred" namespace: "default" + - apiVersion: "lerentis.uploadfilter24.eu/v1beta4" + kind: BitwardenTemplate + metadata: + name: test + spec: + filename: "config.yaml" + name: "test-regcred" + namespace: "default" + template: | + --- + api: + enabled: True + key: {{ bitwarden_lookup("466fc4b0-ffca-4444-8d88-b59d4de3d928", "fields", "key") }} + allowCrossOrigin: false + apps: + "some.app.identifier:some_version": + pubkey: {{ bitwarden_lookup("466fc4b0-ffca-4444-8d88-b59d4de3d928", "fields", "public_key") }} + enabled: true artifacthub.io/license: MIT artifacthub.io/operator: "true" artifacthub.io/changes: | - - kind: changed - description: "Switched to Alpine image" - kind: added - description: "Added CRDs Example to artifactshub" + description: "Added Template type" + - kind: added + description: "Added logo" + - kind: changed + description: "BitwardenSecret now requires a 'secretScope' to be defined. Can eigher be 'login' or 'fields'" + - kind: fixed + description: "fixed hardcoded reference to 'login' even tho secrets could also be in 'fields' scope" artifacthub.io/images: | - name: bitwarden-crd-operator - image: lerentis/bitwarden-crd-operator:0.3.0 + image: lerentis/bitwarden-crd-operator:0.4.0 diff --git a/charts/bitwarden-crd-operator/crds/bitwarden-secrets.yaml b/charts/bitwarden-crd-operator/crds/bitwarden-secrets.yaml index 38b026c..4e420c9 100644 --- a/charts/bitwarden-crd-operator/crds/bitwarden-secrets.yaml +++ b/charts/bitwarden-crd-operator/crds/bitwarden-secrets.yaml @@ -1,3 +1,4 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -12,7 +13,7 @@ spec: shortNames: - bws versions: - - name: v1beta3 + - name: v1beta4 served: true storage: true schema: @@ -34,6 +35,8 @@ spec: type: string secretRef: type: string + secretScope: + type: string required: - secretName id: diff --git a/charts/bitwarden-crd-operator/crds/bitwarden-templates.yaml b/charts/bitwarden-crd-operator/crds/bitwarden-templates.yaml new file mode 100644 index 0000000..fa2212c --- /dev/null +++ b/charts/bitwarden-crd-operator/crds/bitwarden-templates.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: bitwarden-templates.lerentis.uploadfilter24.eu +spec: + scope: Namespaced + group: lerentis.uploadfilter24.eu + names: + kind: BitwardenTemplate + plural: bitwarden-templates + singular: bitwarden-template + shortNames: + - bwt + versions: + - name: v1beta4 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + filename: + type: string + template: + type: string + namespace: + type: string + name: + type: string + required: + - filename + - template + - namespace + - name diff --git a/charts/bitwarden-crd-operator/crds/registry-credentials.yaml b/charts/bitwarden-crd-operator/crds/registry-credentials.yaml index fd7bcb3..c3f4ffb 100644 --- a/charts/bitwarden-crd-operator/crds/registry-credentials.yaml +++ b/charts/bitwarden-crd-operator/crds/registry-credentials.yaml @@ -1,3 +1,4 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -12,7 +13,7 @@ spec: shortNames: - rgc versions: - - name: v1beta3 + - name: v1beta4 served: true storage: true schema: diff --git a/charts/bitwarden-crd-operator/templates/clusterrole.yaml b/charts/bitwarden-crd-operator/templates/clusterrole.yaml index d015f4e..71857bf 100644 --- a/charts/bitwarden-crd-operator/templates/clusterrole.yaml +++ b/charts/bitwarden-crd-operator/templates/clusterrole.yaml @@ -4,7 +4,7 @@ metadata: name: {{ include "bitwarden-crd-operator.serviceAccountName" . }}-role rules: - apiGroups: ["lerentis.uploadfilter24.eu"] - resources: ["bitwarden-secrets", "registry-credentials"] + resources: ["bitwarden-secrets", "registry-credentials", "bitwarden-templates"] verbs: ["get", "watch", "list", "create", "delete", "patch", "update"] - apiGroups: [""] resources: ["secrets"] diff --git a/example.yaml b/example.yaml index 670e6f3..a6e789e 100644 --- a/example.yaml +++ b/example.yaml @@ -1,5 +1,5 @@ --- -apiVersion: "lerentis.uploadfilter24.eu/v1beta3" +apiVersion: "lerentis.uploadfilter24.eu/v1beta4" kind: BitwardenSecret metadata: name: test @@ -8,9 +8,11 @@ spec: - element: secretName: username secretRef: nameofUser + secretScope: login - element: secretName: password secretRef: passwordOfUser + secretScope: login id: "88781348-c81c-4367-9801-550360c21295" name: "test-secret" namespace: "default" \ No newline at end of file diff --git a/example_dockerlogin.yaml b/example_dockerlogin.yaml index 4e34b17..f5f12d0 100644 --- a/example_dockerlogin.yaml +++ b/example_dockerlogin.yaml @@ -1,5 +1,5 @@ --- -apiVersion: "lerentis.uploadfilter24.eu/v1beta3" +apiVersion: "lerentis.uploadfilter24.eu/v1beta4" kind: RegistryCredential metadata: name: test diff --git a/example_template.yaml b/example_template.yaml new file mode 100644 index 0000000..646dc4a --- /dev/null +++ b/example_template.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: "lerentis.uploadfilter24.eu/v1beta4" +kind: BitwardenTemplate +metadata: + name: test +spec: + filename: "config.yaml" + name: "test-template" + namespace: "default" + template: | + --- + api: + enabled: True + key: {{ bitwarden_lookup("466fc4b0-ffca-4444-8d88-b59d4de3d928", "fields", "key") }} + allowCrossOrigin: false + apps: + "some.app.identifier:some_version": + pubkey: {{ bitwarden_lookup("466fc4b0-ffca-4444-8d88-b59d4de3d928", "fields", "public_key") }} + enabled: true \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..513ff57 Binary files /dev/null and b/logo.png differ diff --git a/requirements.txt b/requirements.txt index 7b030cb..9851629 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ kopf==1.35.6 kubernetes==25.3.0 +Jinja2==3.1.2 diff --git a/src/bitwardenCrdOperator.py b/src/bitwardenCrdOperator.py index 4eee3b7..bb1ce72 100755 --- a/src/bitwardenCrdOperator.py +++ b/src/bitwardenCrdOperator.py @@ -1,33 +1,20 @@ #!/usr/bin/env python3 import kopf import os -import subprocess - -def get_secret_from_bitwarden(logger, id): - logger.info(f"Locking up secret with ID: {id}") - return command_wrapper(logger, f"get item {id}") - -def unlock_bw(logger): - token_output = command_wrapper(logger, "unlock --passwordenv BW_PASSWORD") - tokens = token_output.split('"')[1::2] - os.environ["BW_SESSION"] = tokens[1] - logger.info("Signin successful. Session exported") - -def command_wrapper(logger, command): - system_env = dict(os.environ) - sp = subprocess.Popen([f"bw {command}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, shell=True, env=system_env) - out, err = sp.communicate() - if err: - logger.warn(f"Error during bw cli invokement: {err}") - return out.decode(encoding='UTF-8') +from utils.utils import command_wrapper, unlock_bw @kopf.on.startup() def bitwarden_signin(logger, **kwargs): if 'BW_HOST' in os.environ: - command_wrapper(logger, f"config server {os.getenv('BW_HOST')}") + try: + command_wrapper(f"config server {os.getenv('BW_HOST')}") + except: + logger.warn("Revieved none zero exit code from server config") + logger.warn("This is expected from startup") + pass else: logger.info(f"BW_HOST not set. Assuming SaaS installation") - command_wrapper(logger, "login --apikey") + command_wrapper("login --apikey") unlock_bw(logger) diff --git a/src/dockerlogin.py b/src/dockerlogin.py index 4171c27..095d322 100644 --- a/src/dockerlogin.py +++ b/src/dockerlogin.py @@ -3,7 +3,7 @@ import kubernetes import base64 import json -from bitwardenCrdOperator import unlock_bw, get_secret_from_bitwarden +from utils.utils import unlock_bw, get_secret_from_bitwarden def create_dockerlogin(logger, secret, secret_json, username_ref, password_ref, registry): secret.type = "dockerconfigjson" @@ -23,7 +23,7 @@ def create_dockerlogin(logger, secret, secret_json, username_ref, password_ref, secret.data[".dockerconfigjson"] = str(base64.b64encode(json.dumps(auths_dict).encode("utf-8")), "utf-8") return secret -@kopf.on.create('registry-credentials.lerentis.uploadfilter24.eu') +@kopf.on.create('registry-credential.lerentis.uploadfilter24.eu') def create_managed_registry_secret(spec, name, namespace, logger, **kwargs): username_ref = spec.get('usernameRef') password_ref = spec.get('passwordRef') @@ -33,13 +33,13 @@ def create_managed_registry_secret(spec, name, namespace, logger, **kwargs): secret_namespace = spec.get('namespace') unlock_bw(logger) - - secret_json_object = json.loads(get_secret_from_bitwarden(logger, id)) + logger.info(f"Locking up secret with ID: {id}") + secret_json_object = json.loads(get_secret_from_bitwarden(id)) api = kubernetes.client.CoreV1Api() annotations = { - "managed": "registry-credentials.lerentis.uploadfilter24.eu", + "managed": "registry-credential.lerentis.uploadfilter24.eu", "managedObject": f"{namespace}/{name}" } secret = kubernetes.client.V1Secret() @@ -52,11 +52,11 @@ def create_managed_registry_secret(spec, name, namespace, logger, **kwargs): logger.info(f"Registry Secret {secret_namespace}/{secret_name} has been created") -@kopf.on.update('registry-credentials.lerentis.uploadfilter24.eu') +@kopf.on.update('registry-credential.lerentis.uploadfilter24.eu') def my_handler(spec, old, new, diff, **_): pass -@kopf.on.delete('registry-credentials.lerentis.uploadfilter24.eu') +@kopf.on.delete('registry-credential.lerentis.uploadfilter24.eu') def delete_managed_secret(spec, name, namespace, logger, **kwargs): secret_name = spec.get('name') secret_namespace = spec.get('namespace') diff --git a/src/kv.py b/src/kv.py index 59e97b1..598d3d6 100644 --- a/src/kv.py +++ b/src/kv.py @@ -3,7 +3,7 @@ import kubernetes import base64 import json -from bitwardenCrdOperator import unlock_bw, get_secret_from_bitwarden +from utils.utils import unlock_bw, get_secret_from_bitwarden, parse_login_scope, parse_fields_scope def create_kv(secret, secret_json, content_def): secret.type = "Opaque" @@ -15,10 +15,15 @@ def create_kv(secret, secret_json, content_def): _secret_key = value if key == "secretRef": _secret_ref = value - secret.data[_secret_ref] = str(base64.b64encode(secret_json["login"][_secret_key].encode("utf-8")), "utf-8") + if key == "secretScope": + _secret_scope = value + if _secret_scope == "login": + secret.data[_secret_ref] = str(base64.b64encode(parse_login_scope(secret_json, _secret_key).encode("utf-8")), "utf-8") + if _secret_scope == "fields": + secret.data[_secret_ref] = str(base64.b64encode(parse_fields_scope(secret_json, _secret_key).encode("utf-8")), "utf-8") return secret -@kopf.on.create('bitwarden-secrets.lerentis.uploadfilter24.eu') +@kopf.on.create('bitwarden-secret.lerentis.uploadfilter24.eu') def create_managed_secret(spec, name, namespace, logger, body, **kwargs): content_def = body['spec']['content'] @@ -27,13 +32,13 @@ def create_managed_secret(spec, name, namespace, logger, body, **kwargs): secret_namespace = spec.get('namespace') unlock_bw(logger) - - secret_json_object = json.loads(get_secret_from_bitwarden(logger, id)) + logger.info(f"Locking up secret with ID: {id}") + secret_json_object = json.loads(get_secret_from_bitwarden(id)) api = kubernetes.client.CoreV1Api() annotations = { - "managed": "bitwarden-secrets.lerentis.uploadfilter24.eu", + "managed": "bitwarden-secret.lerentis.uploadfilter24.eu", "managedObject": f"{namespace}/{name}" } secret = kubernetes.client.V1Secret() @@ -46,11 +51,11 @@ def create_managed_secret(spec, name, namespace, logger, body, **kwargs): logger.info(f"Secret {secret_namespace}/{secret_name} has been created") -@kopf.on.update('bitwarden-secrets.lerentis.uploadfilter24.eu') +@kopf.on.update('bitwarden-secret.lerentis.uploadfilter24.eu') def my_handler(spec, old, new, diff, **_): pass -@kopf.on.delete('bitwarden-secrets.lerentis.uploadfilter24.eu') +@kopf.on.delete('bitwarden-secret.lerentis.uploadfilter24.eu') def delete_managed_secret(spec, name, namespace, logger, **kwargs): secret_name = spec.get('name') secret_namespace = spec.get('namespace') diff --git a/src/lookups/__init__.py b/src/lookups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lookups/bitwarden_lookup.py b/src/lookups/bitwarden_lookup.py new file mode 100644 index 0000000..d15adb0 --- /dev/null +++ b/src/lookups/bitwarden_lookup.py @@ -0,0 +1,10 @@ +import json + +from utils.utils import get_secret_from_bitwarden, parse_fields_scope, parse_login_scope + +def bitwarden_lookup(id, scope, field): + _secret_json = json.loads(get_secret_from_bitwarden(id)) + if scope == "login": + return parse_login_scope(_secret_json, field) + if scope == "fields": + return parse_fields_scope(_secret_json, field) \ No newline at end of file diff --git a/src/template.py b/src/template.py new file mode 100644 index 0000000..e3abcb9 --- /dev/null +++ b/src/template.py @@ -0,0 +1,72 @@ +import kopf +import base64 +import kubernetes + +from utils.utils import unlock_bw +from lookups.bitwarden_lookup import bitwarden_lookup +from jinja2 import Environment, BaseLoader + + +lookup_func_dict = { + "bitwarden_lookup": bitwarden_lookup, +} + +def render_template(template): + jinja_template = Environment(loader=BaseLoader()).from_string(template) + jinja_template.globals.update(lookup_func_dict) + return jinja_template.render() + +def create_template_secret(secret, filename, template): + secret.type = "Opaque" + secret.data = {} + secret.data[filename] = str(base64.b64encode(render_template(template).encode("utf-8")), "utf-8") + return secret + +@kopf.on.create('bitwarden-template.lerentis.uploadfilter24.eu') +def create_managed_secret(spec, name, namespace, logger, body, **kwargs): + + template = spec.get('template') + filename = spec.get('filename') + secret_name = spec.get('name') + secret_namespace = spec.get('namespace') + + unlock_bw(logger) + + api = kubernetes.client.CoreV1Api() + + annotations = { + "managed": "bitwarden-template.lerentis.uploadfilter24.eu", + "managedObject": f"{namespace}/{name}" + } + secret = kubernetes.client.V1Secret() + secret.metadata = kubernetes.client.V1ObjectMeta(name=secret_name, annotations=annotations) + secret = create_template_secret(secret, filename, template) + + obj = api.create_namespaced_secret( + secret_namespace, secret + ) + + logger.info(f"Secret {secret_namespace}/{secret_name} has been created") + +@kopf.on.update('bitwarden-template.lerentis.uploadfilter24.eu') +def my_handler(spec, old, new, diff, **_): + pass + +@kopf.on.delete('bitwarden-template.lerentis.uploadfilter24.eu') +def delete_managed_secret(spec, name, namespace, logger, **kwargs): + secret_name = spec.get('name') + secret_namespace = spec.get('namespace') + api = kubernetes.client.CoreV1Api() + + try: + api.delete_namespaced_secret(secret_name, secret_namespace) + logger.info(f"Secret {secret_namespace}/{secret_name} has been deleted") + except: + logger.warn(f"Could not delete secret {secret_namespace}/{secret_name}!") + +#if __name__ == '__main__': +# tpl = """ +# Calling the 'bitwarden_lookup' function: +# {{ bitwarden_lookup(2, 2) }} +# """ +# print(render_template(tpl)) \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/utils.py b/src/utils/utils.py new file mode 100644 index 0000000..9c1f543 --- /dev/null +++ b/src/utils/utils.py @@ -0,0 +1,30 @@ +import os +import subprocess + +class BitwardenCommandException(Exception): + pass + +def get_secret_from_bitwarden(id): + return command_wrapper(command=f"get item {id}") + +def unlock_bw(logger): + token_output = command_wrapper("unlock --passwordenv BW_PASSWORD") + tokens = token_output.split('"')[1::2] + os.environ["BW_SESSION"] = tokens[1] + logger.info("Signin successful. Session exported") + +def command_wrapper(command): + system_env = dict(os.environ) + sp = subprocess.Popen([f"bw {command}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, shell=True, env=system_env) + out, err = sp.communicate() + if err: + raise BitwardenCommandException(err) + return out.decode(encoding='UTF-8') + +def parse_login_scope(secret_json, key): + return secret_json["login"][key] + +def parse_fields_scope(secret_json, key): + for entry in secret_json["fields"]: + if entry['name'] == key: + return entry['value']