diff --git a/clusters/atlas/flux-system/platform/cert-manager/kustomization.yaml b/clusters/atlas/flux-system/platform/cert-manager/kustomization.yaml index 21a9dc9..63469af 100644 --- a/clusters/atlas/flux-system/platform/cert-manager/kustomization.yaml +++ b/clusters/atlas/flux-system/platform/cert-manager/kustomization.yaml @@ -15,6 +15,5 @@ spec: namespace: flux-system targetNamespace: cert-manager dependsOn: - - name: cert-manager-cleanup - name: helm wait: true diff --git a/clusters/atlas/flux-system/platform/kustomization.yaml b/clusters/atlas/flux-system/platform/kustomization.yaml index 8ee08d7..b689cc0 100644 --- a/clusters/atlas/flux-system/platform/kustomization.yaml +++ b/clusters/atlas/flux-system/platform/kustomization.yaml @@ -4,7 +4,6 @@ kind: Kustomization resources: - core/kustomization.yaml - helm/kustomization.yaml - - cert-manager-cleanup/kustomization.yaml - cert-manager/kustomization.yaml - metallb/kustomization.yaml - traefik/kustomization.yaml diff --git a/infrastructure/modules/base/storageclass/asteria-encrypted.yaml b/infrastructure/modules/base/storageclass/asteria-encrypted.yaml new file mode 100644 index 0000000..a6eb566 --- /dev/null +++ b/infrastructure/modules/base/storageclass/asteria-encrypted.yaml @@ -0,0 +1,24 @@ +# infrastructure/modules/base/storageclass/asteria-encrypted.yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: asteria-encrypted +parameters: + diskSelector: asteria + fromBackup: "" + numberOfReplicas: "2" + staleReplicaTimeout: "30" + fsType: "ext4" + replicaAutoBalance: "least-effort" + dataLocality: "disabled" + encrypted: "true" + csi.storage.k8s.io/provisioner-secret-name: ${pvc.name} + csi.storage.k8s.io/provisioner-secret-namespace: ${pvc.namespace} + csi.storage.k8s.io/node-publish-secret-name: ${pvc.name} + csi.storage.k8s.io/node-publish-secret-namespace: ${pvc.namespace} + csi.storage.k8s.io/node-stage-secret-name: ${pvc.name} + csi.storage.k8s.io/node-stage-secret-namespace: ${pvc.namespace} +provisioner: driver.longhorn.io +reclaimPolicy: Retain +allowVolumeExpansion: true +volumeBindingMode: Immediate diff --git a/infrastructure/modules/base/storageclass/kustomization.yaml b/infrastructure/modules/base/storageclass/kustomization.yaml index 704dd73..44d79c7 100644 --- a/infrastructure/modules/base/storageclass/kustomization.yaml +++ b/infrastructure/modules/base/storageclass/kustomization.yaml @@ -3,4 +3,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - asteria.yaml + - asteria-encrypted.yaml - astreae.yaml diff --git a/services/finance/actual-budget-data-pvc.yaml b/services/finance/actual-budget-data-pvc.yaml index 7016cda..2da64a8 100644 --- a/services/finance/actual-budget-data-pvc.yaml +++ b/services/finance/actual-budget-data-pvc.yaml @@ -2,11 +2,11 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: actual-budget-data + name: actual-budget-data-encrypted namespace: finance spec: accessModes: ["ReadWriteOnce"] - storageClassName: asteria + storageClassName: asteria-encrypted resources: requests: storage: 10Gi diff --git a/services/finance/actual-budget-deployment.yaml b/services/finance/actual-budget-deployment.yaml index c4aeeaa..55186b2 100644 --- a/services/finance/actual-budget-deployment.yaml +++ b/services/finance/actual-budget-deployment.yaml @@ -169,7 +169,7 @@ spec: volumes: - name: actual-data persistentVolumeClaim: - claimName: actual-budget-data + claimName: actual-budget-data-encrypted - name: actual-openid-bootstrap-script configMap: name: actual-openid-bootstrap-script diff --git a/services/finance/actual-budget-ingress.yaml b/services/finance/actual-budget-ingress.yaml index 4cbc9e6..c6eaee7 100644 --- a/services/finance/actual-budget-ingress.yaml +++ b/services/finance/actual-budget-ingress.yaml @@ -6,7 +6,7 @@ metadata: namespace: finance annotations: kubernetes.io/ingress.class: traefik - traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure traefik.ingress.kubernetes.io/router.tls: "true" cert-manager.io/cluster-issuer: letsencrypt spec: diff --git a/services/finance/finance-secrets-ensure-job.yaml b/services/finance/finance-secrets-ensure-job.yaml index 5de20af..67f06cb 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-4 + name: finance-secrets-ensure-5 namespace: finance spec: backoffLimit: 1 diff --git a/services/finance/finance-secrets-ensure-rbac.yaml b/services/finance/finance-secrets-ensure-rbac.yaml new file mode 100644 index 0000000..5f70578 --- /dev/null +++ b/services/finance/finance-secrets-ensure-rbac.yaml @@ -0,0 +1,24 @@ +# services/finance/finance-secrets-ensure-rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: finance-secrets-ensure + namespace: finance +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: finance-secrets-ensure + namespace: finance +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: finance-secrets-ensure +subjects: + - kind: ServiceAccount + name: finance-secrets-ensure + namespace: finance diff --git a/services/finance/firefly-ingress.yaml b/services/finance/firefly-ingress.yaml index bd01661..40324a9 100644 --- a/services/finance/firefly-ingress.yaml +++ b/services/finance/firefly-ingress.yaml @@ -6,7 +6,7 @@ metadata: namespace: finance annotations: kubernetes.io/ingress.class: traefik - traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure traefik.ingress.kubernetes.io/router.tls: "true" cert-manager.io/cluster-issuer: letsencrypt spec: diff --git a/services/finance/kustomization.yaml b/services/finance/kustomization.yaml index 11cb4ab..e4c414f 100644 --- a/services/finance/kustomization.yaml +++ b/services/finance/kustomization.yaml @@ -6,6 +6,7 @@ resources: - namespace.yaml - serviceaccount.yaml - portal-rbac.yaml + - finance-secrets-ensure-rbac.yaml - actual-budget-data-pvc.yaml - firefly-storage-pvc.yaml - finance-secrets-ensure-job.yaml diff --git a/services/finance/scripts/finance_secrets_ensure.py b/services/finance/scripts/finance_secrets_ensure.py index 9a04ad0..198ffe6 100644 --- a/services/finance/scripts/finance_secrets_ensure.py +++ b/services/finance/scripts/finance_secrets_ensure.py @@ -3,6 +3,7 @@ import base64 import json import os import secrets +import ssl import sys import urllib.error import urllib.request @@ -20,18 +21,81 @@ def require_value(label: str, value: str) -> None: raise RuntimeError(f"missing {label}") -def http_json(method: str, url: str, headers=None, payload=None): +def http_json(method: str, url: str, headers=None, payload=None, context=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: + with urllib.request.urlopen(req, timeout=15, context=context) as resp: body = resp.read() if not body: return resp.status, None return resp.status, json.loads(body.decode()) +def k8s_context() -> ssl.SSLContext: + ca_path = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") + if ca_path.exists(): + return ssl.create_default_context(cafile=str(ca_path)) + return ssl.create_default_context() + + +def k8s_api_url(path: str) -> str: + host = os.environ.get("KUBERNETES_SERVICE_HOST") + port = os.environ.get("KUBERNETES_SERVICE_PORT", "443") + if not host: + raise RuntimeError("missing kubernetes service host") + return f"https://{host}:{port}{path}" + + +def k8s_get_secret(namespace: str, name: str, token: str): + try: + _, body = http_json( + "GET", + k8s_api_url(f"/api/v1/namespaces/{namespace}/secrets/{name}"), + headers={"Authorization": f"Bearer {token}"}, + context=k8s_context(), + ) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + return body + + +def k8s_create_secret(namespace: str, name: str, token: str, string_data: dict): + payload = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": name, "namespace": namespace}, + "type": "Opaque", + "stringData": string_data, + } + try: + status, _ = http_json( + "POST", + k8s_api_url(f"/api/v1/namespaces/{namespace}/secrets"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + payload=payload, + context=k8s_context(), + ) + except urllib.error.HTTPError as exc: + if exc.code == 409: + return + raise + if status not in (200, 201): + raise RuntimeError(f"k8s secret create failed for {name} (status {status})") + + +def decode_secret_value(value: str) -> str: + if not value: + return "" + return base64.b64decode(value.encode()).decode("utf-8") + + def vault_login(vault_addr: str, role: str, jwt: str) -> str: status, body = http_json( "POST", @@ -152,17 +216,63 @@ def ensure_actual_db(vault_addr: str, token: str): ) +def ensure_actual_encryption(vault_addr: str, token: str, sa_token: str): + namespace = os.environ.get("FINANCE_NAMESPACE", "finance") + secret_name = os.environ.get("ACTUAL_BUDGET_PVC_NAME", "actual-budget-data-encrypted") + if not sa_token: + raise RuntimeError("missing service account token for k8s") + + vault_data = vault_read(vault_addr, token, "finance/actual-encryption") + vault_key = vault_data.get("CRYPTO_KEY_VALUE", "") + + k8s_secret = k8s_get_secret(namespace, secret_name, sa_token) + k8s_key = "" + if k8s_secret: + data = k8s_secret.get("data", {}) or {} + k8s_key = decode_secret_value(data.get("CRYPTO_KEY_VALUE", "")) + + if vault_key and k8s_key and vault_key != k8s_key: + raise RuntimeError("actual encryption key mismatch between vault and k8s") + + key = vault_key or k8s_key + provider = "secret" + if not key: + key = secrets.token_urlsafe(48) + vault_write( + vault_addr, + token, + "finance/actual-encryption", + {"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider}, + ) + elif not vault_key: + vault_write( + vault_addr, + token, + "finance/actual-encryption", + {"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider}, + ) + + if not k8s_secret: + k8s_create_secret( + namespace, + secret_name, + sa_token, + {"CRYPTO_KEY_VALUE": key, "CRYPTO_KEY_PROVIDER": provider}, + ) + + 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: + sa_token = read_file(Path("/var/run/secrets/kubernetes.io/serviceaccount/token")) + if not sa_token: raise RuntimeError("missing service account token") - token = vault_login(vault_addr, vault_role, jwt) + token = vault_login(vault_addr, vault_role, sa_token) ensure_firefly_db(vault_addr, token) ensure_firefly_secrets(vault_addr, token) ensure_actual_db(vault_addr, token) + ensure_actual_encryption(vault_addr, token, sa_token) print("finance secrets ensured") return 0