From dd5a8abd551a8bd5fde2ef649d7c0ccd544760dc Mon Sep 17 00:00:00 2001 From: Alan Bridgeman Date: Sun, 5 Apr 2026 15:38:21 -0500 Subject: [PATCH] Attempting to add support to use Kubernetes secrets for vault root token, unseal keys and app role data --- .gitignore | 5 +- Dockerfile | 14 ++- entrypoint.sh | 2 +- setup-scripts/KubernetesSecretsManager.py | 117 ++++++++++++++++++++++ setup-scripts/app-role-access.py | 27 +++-- setup-scripts/prod-setup.py | 26 +++-- setup-scripts/requirements.txt | 1 + 7 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 setup-scripts/KubernetesSecretsManager.py create mode 100644 setup-scripts/requirements.txt diff --git a/.gitignore b/.gitignore index 92a0531..a5b8ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ .env # Ignore VSCode settings (that don't need to be shared) -.vscode/ \ No newline at end of file +.vscode/ + +# Ignore Python virtual environment +.venv \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 75039bb..7d9d906 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM hashicorp/vault:latest +WORKDIR /vault/setup + # Install Bash RUN apk add --no-cache --upgrade bash @@ -11,6 +13,10 @@ RUN python3 -m venv .venv \ && python -m ensurepip \ && pip install --no-cache --upgrade pip setuptools +# Install any needed dependencies +COPY ./setup-scripts/requirements.txt ./ +RUN source .venv/bin/activate && pip install --no-cache -r requirements.txt + # Needed for parsing JSON in Bash (which is needed to parse the unseal keys and root token) RUN apk add --no-cache jq @@ -18,11 +24,11 @@ RUN apk add --no-cache jq COPY vault-config.hcl /vault/config/vault-config.hcl # Copy the startup script into the container (also verifying it's encoded properly) -COPY ./entrypoint.sh /entrypoint.sh -RUN dos2unix /entrypoint.sh +COPY ./entrypoint.sh ./ +RUN dos2unix ./entrypoint.sh # Copy the Python startup stuff into the container -COPY ./setup-scripts /setup-scripts +COPY ./setup-scripts ./setup-scripts # Copy the snapshot server Python code into the container COPY ./snapshot-server /snapshot-server @@ -33,4 +39,4 @@ COPY ./snapshot-server /snapshot-server # | 8300 | Custom snapshot server (for creating and serving backups over HTTP) | EXPOSE 8200 8300 -ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/bin/bash", "/vault/setup/entrypoint.sh"] \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index 7830f85..e38c564 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -123,7 +123,7 @@ start_and_wait_for_vault() { start_and_wait_for_vault -python3 /setup-scripts/prod-setup.py +source .venv/bin/activate && python3 /setup-scripts/prod-setup.py #init_vault #unseal_vault diff --git a/setup-scripts/KubernetesSecretsManager.py b/setup-scripts/KubernetesSecretsManager.py new file mode 100644 index 0000000..f9033ab --- /dev/null +++ b/setup-scripts/KubernetesSecretsManager.py @@ -0,0 +1,117 @@ +from kubernetes import client, config +from kubernetes.client.rest import ApiException +import os + +class KubernetesSecretManager: + def __init__(self): + # This magic line automatically reads the KSA token mounted at + # /var/run/secrets/kubernetes.io/serviceaccount/token inside the pod! + config.load_incluster_config() + self.api_instance = client.CoreV1Api() + + # Read namespace from standard K8s environment variable, default to 'default' + self.namespace = os.environ.get('POD_NAMESPACE', 'default') + + def publish_unseal_keys_credentials(self, secret_name: str, unseal_keys: list): + """Publish the unseal keys as a Kubernetes Secret + + Args: + secret_name (str): The name of the Kubernetes Secret to create + unseal_keys (list): The vault's unseal keys + """ + + # Define the secret payload using string_data (K8s auto-base64 encodes it for us) + secret_body = client.V1Secret( + metadata=client.V1ObjectMeta(name=secret_name), + type="Opaque", + string_data={ + f'unseal_key_{i}': key for i, key in enumerate(unseal_keys) + } + ) + + try: + # Attempt to create the secret + self.api_instance.create_namespaced_secret( + namespace=self.namespace, + body=secret_body + ) + print("[SUCCESS] Kubernetes Secret created.") + + except ApiException as e: + # Status 409 means the Secret already exists + if e.status == 409: + print("[ERROR] Secret already exists.") + else: + print(f"[ERROR] Failed to publish K8s Secret: {e}") + raise e + + def publish_root_token_credentials(self, secret_name: str, root_token: str): + """Publish the root token as a Kubernetes Secret + + Args: + secret_name (str): The name of the Kubernetes Secret to create + root_token (str): The vault's root token + """ + + # Define the secret payload using string_data (K8s auto-base64 encodes it for us) + secret_body = client.V1Secret( + metadata=client.V1ObjectMeta(name=secret_name), + type="Opaque", + string_data={ + 'root_token': root_token + } + ) + + try: + # Attempt to create the secret + self.api_instance.create_namespaced_secret( + namespace=self.namespace, + body=secret_body + ) + print("[SUCCESS] Kubernetes Secret created.") + + except ApiException as e: + # Status 409 means the Secret already exists + if e.status == 409: + print("[ERROR] Secret already exists.") + else: + print(f"[ERROR] Failed to publish K8s Secret: {e}") + raise e + + def publish_approle_credentials(self, secret_name: str, role_id: str, secret_id: str): + """ + Creates or updates a Kubernetes Secret with the Vault AppRole credentials. + """ + print(f"Publishing AppRole credentials to Kubernetes Secret: {secret_name}") + + # Define the secret payload using string_data (K8s auto-base64 encodes it for us) + secret_body = client.V1Secret( + metadata=client.V1ObjectMeta(name=secret_name), + type="Opaque", + string_data={ + "role_id": role_id, + "secret_id": secret_id + } + ) + + try: + # Attempt to create the secret + self.api_instance.create_namespaced_secret( + namespace=self.namespace, + body=secret_body + ) + print("[SUCCESS] Kubernetes Secret created.") + + except ApiException as e: + # Status 409 means the Secret already exists + if e.status == 409: + print("[INFO] Secret already exists. Patching with new credentials...") + self.api_instance.patch_namespaced_secret( + name=secret_name, + namespace=self.namespace, + body=secret_body + ) + print("[SUCCESS] Kubernetes Secret updated.") + else: + print(f"[ERROR] Failed to publish K8s Secret: {e}") + raise e \ No newline at end of file diff --git a/setup-scripts/app-role-access.py b/setup-scripts/app-role-access.py index 8596d03..f25c78b 100644 --- a/setup-scripts/app-role-access.py +++ b/setup-scripts/app-role-access.py @@ -15,6 +15,7 @@ import os, sys, subprocess, json, logging from CommandRunner import CommandRunner +from KubernetesSecretsManager import KubernetesSecretsManager #def run_command(command): # try: @@ -303,19 +304,31 @@ def main(token: str): # Create a role role_id, secret_id = create_app_role(role_name, policy_name) - # Save the role_id and secret_id to a backup file - save_role_vars_to_backup_file(role_name, role_id, secret_id) + if os.environ.get('MODE') == 'file': + # Save the role_id and secret_id to a backup file + save_role_vars_to_backup_file(role_name, role_id, secret_id) - # Save the role_id and secret_id to a file - save_role_vars_to_file(role_id, secret_id) + # Save the role_id and secret_id to a file + save_role_vars_to_file(role_id, secret_id) + else: + # In Kubernetes mode we will create Kubernetes secrets with the role_id and secret_id instead of writing to files + k8s_manager = KubernetesSecretsManager() + if os.environ.get('ROLE_ID_SECRET_NAME') and os.environ.get('SECRET_ID_SECRET_NAME') and os.environ.get('ROLE_ID_SECRET_NAME') == os.environ.get('SECRET_ID_SECRET_NAME'): + k8s_manager.publish_approle_credentials(os.environ.get('ROLE_ID_SECRET_NAME'), role_id, secret_id) else: # Get the existing role_id and secret_id role_id = get_role_id(role_name) secret_id = get_secret_id(role_name) - # Save the role_id and secret_id to a file - # QUESTION: Should this be conditional on if the file already exists or not? - save_role_vars_to_file(role_id, secret_id) + if os.environ.get('MODE') == 'file': + # Save the role_id and secret_id to a file + # QUESTION: Should this be conditional on if the file already exists or not? + save_role_vars_to_file(role_id, secret_id) + else: + # In Kubernetes mode we will create Kubernetes secrets with the role_id and secret_id instead of writing to files + k8s_manager = KubernetesSecretsManager() + if os.environ.get('ROLE_ID_SECRET_NAME') and os.environ.get('SECRET_ID_SECRET_NAME') and os.environ.get('ROLE_ID_SECRET_NAME') == os.environ.get('SECRET_ID_SECRET_NAME'): + k8s_manager.publish_approle_credentials(os.environ.get('ROLE_ID_SECRET_NAME'), role_id, secret_id) logging.info('AppRole setup complete') diff --git a/setup-scripts/prod-setup.py b/setup-scripts/prod-setup.py index 26a9d1b..b759aed 100644 --- a/setup-scripts/prod-setup.py +++ b/setup-scripts/prod-setup.py @@ -1,5 +1,6 @@ import os, json from CommandRunner import CommandRunner +from KubernetesSecretsManager import KubernetesSecretsManager class Initializer: def check_if_initialized(self) -> bool: @@ -83,8 +84,13 @@ class Initializer: # UPDATE: Is mounted as a volume instead #CommandRunner.run_command('mkdir /vault/creds') - self.create_unseal_keys_file() - self.create_root_token_file() + if os.environ.get('MODE') == 'file': + self.create_unseal_keys_file() + self.create_root_token_file() + else: + k8s_manager = KubernetesSecretsManager() + k8s_manager.publish_unseal_keys_credentials('vault-unseal-keys', self.unseal_keys) + k8s_manager.publish_root_token_credentials('vault-root-token', self.root_token) def is_vault_sealed(self) -> bool: """Check if the vault is sealed or not @@ -164,7 +170,7 @@ class Initializer: print(f'Policy Capabilities: {os.getenv("POLICY_CAPABILITIES")}') # Run the custom entrypoint Python script - CommandRunner.run_command_in_real_time(f'python3 /setup-scripts/app-role-access.py {self.root_token}') + CommandRunner.run_command_in_real_time(f'source .venv/bin/activate && python3 /vault/setup/setup-scripts/app-role-access.py {self.root_token}') def main(): initializer = Initializer() @@ -174,11 +180,15 @@ def main(): if not initializer.check_if_initialized(): initializer.init_vault() - # This is just a safety check/measure to ensure the script can continue - # The only time this would likely be triggered is if there was some kind of PV desyncronization but it shouldn't really happen. - if not initializer.check_root_token_and_unseal_keys_files_exist(): - raise RuntimeError('Vault is in an inconsistent state for this script to continue. Please ensure the vault can be initialized OR both the root token and unseal keys files exist alongside and initialized vault.') - + if os.environ.get('MODE') == 'file': + # This is just a safety check / measure to ensure the script can continue + # The only time this would likely be triggered is if there was some kind of PV desyncronization but it shouldn't really happen. + if not initializer.check_root_token_and_unseal_keys_files_exist(): + raise RuntimeError('Vault is in an inconsistent state for this script to continue. Please ensure the vault can be initialized OR both the root token and unseal keys files exist alongside and initialized vault.') + else: + # In Kubernetes mode we expect the secrets to be created so we can pull them back down if needed, but we don't necessarily need to check for them here since we aren't relying on files for the credentials in this mode. + pass + # Check if the vault is sealed (as we need to unseal it to set it up) if initializer.is_vault_sealed(): initializer.unseal_vault() diff --git a/setup-scripts/requirements.txt b/setup-scripts/requirements.txt new file mode 100644 index 0000000..70cfe55 --- /dev/null +++ b/setup-scripts/requirements.txt @@ -0,0 +1 @@ +kubernetes \ No newline at end of file