diff --git a/services/finance/finance-secrets-ensure-job.yaml b/services/finance/finance-secrets-ensure-job.yaml index 103c876..5de20af 100644 --- a/services/finance/finance-secrets-ensure-job.yaml +++ b/services/finance/finance-secrets-ensure-job.yaml @@ -2,7 +2,7 @@ apiVersion: batch/v1 kind: Job metadata: - name: finance-secrets-ensure-3 + name: finance-secrets-ensure-4 namespace: finance spec: backoffLimit: 1 @@ -31,13 +31,12 @@ spec: node-role.kubernetes.io/worker: "true" containers: - name: ensure - image: alpine:3.20 + image: python:3.11-alpine command: ["/bin/sh", "-c"] args: - | set -e - apk add --no-cache bash curl jq >/dev/null - exec bash /scripts/finance_secrets_ensure.sh + exec python /scripts/finance_secrets_ensure.py env: - name: VAULT_ROLE value: finance-secrets diff --git a/services/finance/kustomization.yaml b/services/finance/kustomization.yaml index 2189834..11cb4ab 100644 --- a/services/finance/kustomization.yaml +++ b/services/finance/kustomization.yaml @@ -28,4 +28,4 @@ configMapGenerator: - firefly_user_sync.php=scripts/firefly_user_sync.php - name: finance-secrets-ensure-script files: - - finance_secrets_ensure.sh=scripts/finance_secrets_ensure.sh + - finance_secrets_ensure.py=scripts/finance_secrets_ensure.py diff --git a/services/finance/scripts/finance_secrets_ensure.py b/services/finance/scripts/finance_secrets_ensure.py new file mode 100644 index 0000000..9a04ad0 --- /dev/null +++ b/services/finance/scripts/finance_secrets_ensure.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import secrets +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def read_file(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +def require_value(label: str, value: str) -> None: + if not value: + raise RuntimeError(f"missing {label}") + + +def http_json(method: str, url: str, headers=None, payload=None): + data = None + if payload is not None: + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read() + if not body: + return resp.status, None + return resp.status, json.loads(body.decode()) + + +def vault_login(vault_addr: str, role: str, jwt: str) -> str: + status, body = http_json( + "POST", + f"{vault_addr}/v1/auth/kubernetes/login", + headers={"Content-Type": "application/json"}, + payload={"jwt": jwt, "role": role}, + ) + if status != 200 or not body: + raise RuntimeError("vault login failed") + token = body.get("auth", {}).get("client_token") + if not token: + raise RuntimeError("vault login returned no token") + return token + + +def vault_read(vault_addr: str, token: str, path: str): + try: + status, body = http_json( + "GET", + f"{vault_addr}/v1/kv/data/atlas/{path}", + headers={"X-Vault-Token": token}, + ) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return {} + raise + if status != 200 or not body: + return {} + return body.get("data", {}).get("data", {}) or {} + + +def vault_write(vault_addr: str, token: str, path: str, data: dict): + payload = {"data": data} + status, _ = http_json( + "POST", + f"{vault_addr}/v1/kv/data/atlas/{path}", + headers={"X-Vault-Token": token, "Content-Type": "application/json"}, + payload=payload, + ) + if status not in (200, 204): + raise RuntimeError(f"vault write failed for {path} (status {status})") + + +def ensure_firefly_db(vault_addr: str, token: str): + base = Path("/secrets/firefly-db") + host = read_file(base / "DB_HOST") or read_file(base / "DB_HOSTNAME") + port = read_file(base / "DB_PORT") + db_name = read_file(base / "DB_DATABASE") or read_file(base / "DB_NAME") + user = read_file(base / "DB_USERNAME") or read_file(base / "DB_USER") + password = read_file(base / "DB_PASSWORD") or read_file(base / "DB_PASS") + + require_value("firefly-db/DB_HOST", host) + require_value("firefly-db/DB_PORT", port) + require_value("firefly-db/DB_DATABASE", db_name) + require_value("firefly-db/DB_USERNAME", user) + require_value("firefly-db/DB_PASSWORD", password) + + vault_write( + vault_addr, + token, + "finance/firefly-db", + { + "DB_HOST": host, + "DB_PORT": port, + "DB_DATABASE": db_name, + "DB_USERNAME": user, + "DB_PASSWORD": password, + }, + ) + + +def ensure_firefly_secrets(vault_addr: str, token: str): + current = vault_read(vault_addr, token, "finance/firefly-secrets") + app_key = current.get("APP_KEY") + if not app_key: + app_key = "base64:" + base64.b64encode(secrets.token_bytes(32)).decode() + cron_token = current.get("STATIC_CRON_TOKEN") + if not cron_token: + cron_token = secrets.token_urlsafe(32) + vault_write( + vault_addr, + token, + "finance/firefly-secrets", + {"APP_KEY": app_key, "STATIC_CRON_TOKEN": cron_token}, + ) + + +def ensure_actual_db(vault_addr: str, token: str): + base = Path("/secrets/actualbudget-db") + if not base.exists(): + return + host = read_file(base / "DB_HOST") or read_file(base / "DB_HOSTNAME") + port = read_file(base / "DB_PORT") + db_name = read_file(base / "DB_DATABASE") or read_file(base / "DB_NAME") + user = read_file(base / "DB_USERNAME") or read_file(base / "DB_USER") + password = read_file(base / "DB_PASSWORD") or read_file(base / "DB_PASS") + + if not any([host, port, db_name, user, password]): + return + + require_value("actualbudget-db/DB_HOST", host) + require_value("actualbudget-db/DB_PORT", port) + require_value("actualbudget-db/DB_DATABASE", db_name) + require_value("actualbudget-db/DB_USERNAME", user) + require_value("actualbudget-db/DB_PASSWORD", password) + + vault_write( + vault_addr, + token, + "finance/actual-db", + { + "DB_HOST": host, + "DB_PORT": port, + "DB_DATABASE": db_name, + "DB_USERNAME": user, + "DB_PASSWORD": password, + }, + ) + + +def main() -> int: + vault_addr = os.environ.get("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200") + vault_role = os.environ.get("VAULT_ROLE", "finance-secrets") + jwt = read_file(Path("/var/run/secrets/kubernetes.io/serviceaccount/token")) + if not jwt: + raise RuntimeError("missing service account token") + + token = vault_login(vault_addr, vault_role, jwt) + ensure_firefly_db(vault_addr, token) + ensure_firefly_secrets(vault_addr, token) + ensure_actual_db(vault_addr, token) + print("finance secrets ensured") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + print(f"finance secrets ensure failed: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/services/finance/scripts/finance_secrets_ensure.sh b/services/finance/scripts/finance_secrets_ensure.sh deleted file mode 100755 index 33a2d73..0000000 --- a/services/finance/scripts/finance_secrets_ensure.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -vault_addr="${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200}" -vault_role="${VAULT_ROLE:-finance-secrets}" -jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" -login_payload="$(jq -nc --arg jwt "${jwt}" --arg role "${vault_role}" '{jwt:$jwt, role:$role}')" -vault_token="$(curl -sS --request POST --data "${login_payload}" \ - "${vault_addr}/v1/auth/kubernetes/login" | jq -r '.auth.client_token')" -if [ -z "${vault_token}" ] || [ "${vault_token}" = "null" ]; then - echo "vault login failed" >&2 - exit 1 -fi - -read_secret() { - path="$1" - if [ -f "${path}" ]; then - cat "${path}" - fi -} - -require_value() { - label="$1" - value="$2" - if [ -z "${value}" ]; then - echo "missing ${label}" >&2 - exit 1 - fi -} - -vault_read() { - path="$1" - key="$2" - curl -sS -H "X-Vault-Token: ${vault_token}" \ - "${vault_addr}/v1/kv/data/atlas/${path}" 2>/dev/null | \ - jq -r --arg key "${key}" '.data.data[$key] // empty' 2>/dev/null || true -} - -vault_write_json() { - path="$1" - payload="$2" - curl -sS -X POST -H "X-Vault-Token: ${vault_token}" \ - -d "${payload}" "${vault_addr}/v1/kv/data/atlas/${path}" >/dev/null -} - -firefly_db_host="$(read_secret /secrets/firefly-db/DB_HOST)" -if [ -z "${firefly_db_host}" ]; then - firefly_db_host="$(read_secret /secrets/firefly-db/DB_HOSTNAME)" -fi -firefly_db_port="$(read_secret /secrets/firefly-db/DB_PORT)" -firefly_db_name="$(read_secret /secrets/firefly-db/DB_DATABASE)" -if [ -z "${firefly_db_name}" ]; then - firefly_db_name="$(read_secret /secrets/firefly-db/DB_NAME)" -fi -firefly_db_user="$(read_secret /secrets/firefly-db/DB_USERNAME)" -if [ -z "${firefly_db_user}" ]; then - firefly_db_user="$(read_secret /secrets/firefly-db/DB_USER)" -fi -firefly_db_pass="$(read_secret /secrets/firefly-db/DB_PASSWORD)" -if [ -z "${firefly_db_pass}" ]; then - firefly_db_pass="$(read_secret /secrets/firefly-db/DB_PASS)" -fi - -require_value "firefly-db/DB_HOST" "${firefly_db_host}" -require_value "firefly-db/DB_PORT" "${firefly_db_port}" -require_value "firefly-db/DB_DATABASE" "${firefly_db_name}" -require_value "firefly-db/DB_USERNAME" "${firefly_db_user}" -require_value "firefly-db/DB_PASSWORD" "${firefly_db_pass}" - -firefly_payload="$(jq -nc \ - --arg host "${firefly_db_host}" \ - --arg port "${firefly_db_port}" \ - --arg db "${firefly_db_name}" \ - --arg user "${firefly_db_user}" \ - --arg pass "${firefly_db_pass}" \ - '{data:{DB_HOST:$host, DB_PORT:$port, DB_DATABASE:$db, DB_USERNAME:$user, DB_PASSWORD:$pass}}')" -vault_write_json "finance/firefly-db" "${firefly_payload}" - -app_key="$(vault_read "finance/firefly-secrets" "APP_KEY")" -if [ -z "${app_key}" ]; then - app_key="base64:$(head -c 32 /dev/urandom | base64 | tr -d '\n')" -fi -cron_token="$(vault_read "finance/firefly-secrets" "STATIC_CRON_TOKEN")" -if [ -z "${cron_token}" ]; then - cron_token="$(head -c 32 /dev/urandom | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')" -fi -firefly_secret_payload="$(jq -nc \ - --arg app_key "${app_key}" \ - --arg cron "${cron_token}" \ - '{data:{APP_KEY:$app_key, STATIC_CRON_TOKEN:$cron}}')" -vault_write_json "finance/firefly-secrets" "${firefly_secret_payload}" - -if [ -d /secrets/actualbudget-db ]; then - actual_db_host="$(read_secret /secrets/actualbudget-db/DB_HOST)" - if [ -z "${actual_db_host}" ]; then - actual_db_host="$(read_secret /secrets/actualbudget-db/DB_HOSTNAME)" - fi - actual_db_port="$(read_secret /secrets/actualbudget-db/DB_PORT)" - actual_db_name="$(read_secret /secrets/actualbudget-db/DB_DATABASE)" - if [ -z "${actual_db_name}" ]; then - actual_db_name="$(read_secret /secrets/actualbudget-db/DB_NAME)" - fi - actual_db_user="$(read_secret /secrets/actualbudget-db/DB_USERNAME)" - if [ -z "${actual_db_user}" ]; then - actual_db_user="$(read_secret /secrets/actualbudget-db/DB_USER)" - fi - actual_db_pass="$(read_secret /secrets/actualbudget-db/DB_PASSWORD)" - if [ -z "${actual_db_pass}" ]; then - actual_db_pass="$(read_secret /secrets/actualbudget-db/DB_PASS)" - fi - - if [ -n "${actual_db_host}${actual_db_port}${actual_db_name}${actual_db_user}${actual_db_pass}" ]; then - require_value "actualbudget-db/DB_HOST" "${actual_db_host}" - require_value "actualbudget-db/DB_PORT" "${actual_db_port}" - require_value "actualbudget-db/DB_DATABASE" "${actual_db_name}" - require_value "actualbudget-db/DB_USERNAME" "${actual_db_user}" - require_value "actualbudget-db/DB_PASSWORD" "${actual_db_pass}" - - actual_payload="$(jq -nc \ - --arg host "${actual_db_host}" \ - --arg port "${actual_db_port}" \ - --arg db "${actual_db_name}" \ - --arg user "${actual_db_user}" \ - --arg pass "${actual_db_pass}" \ - '{data:{DB_HOST:$host, DB_PORT:$port, DB_DATABASE:$db, DB_USERNAME:$user, DB_PASSWORD:$pass}}')" - vault_write_json "finance/actual-db" "${actual_payload}" - else - echo "actualbudget-db secret empty; skipping actual-db vault write" >&2 - fi -fi