From deb031cc2c3697cd8869126695f05e4f7cced0f4 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 4 Jun 2026 22:41:41 +0000 Subject: [PATCH] feat(tripit): encrypted personal-document vault PVC + DOCUMENT_ENCRYPTION_KEY Add a proxmox-lvm-encrypted RWO PVC (tripit-personal-documents) mounted at /data/personal-documents on the app container, PERSONAL_STORAGE_DIR env, and the DOCUMENT_ENCRYPTION_KEY ExternalSecret entry (seeded in Vault secret/tripit). A root chown init-container makes the block volume writable by the non-root app without touching the NFS doc vault. Backs the new owner-only encrypted personal document vault in the tripit app. Co-Authored-By: Claude Opus 4.8 --- .claude/reference/service-catalog.md | 2 +- stacks/tripit/main.tf | 105 ++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/.claude/reference/service-catalog.md b/.claude/reference/service-catalog.md index 86016d62..f3079e3b 100644 --- a/.claude/reference/service-catalog.md +++ b/.claude/reference/service-catalog.md @@ -115,7 +115,7 @@ | priority-pass | Boarding pass color transformer | priority-pass | | status-page | Status page | status-page | | plotting-book | Book plotting/world-building app | plotting-book | -| tripit | Self-hosted TripIt-clone travel-itinerary PWA (FastAPI + SvelteKit SPA, same-origin). CNPG (`tripit` db, Vault static role `pg-tripit`) + RWX NFS doc vault (`/srv/nfs/tripit-documents`). `auth=required` (Authentik forward-auth, reads `X-authentik-email`); second `auth=none` ingress on `/api/calendar` for HMAC-token-gated `.ics` feed. Email-ingest CronJob `tripit-ingest-mail` (`*/30`) parses me@viktorbarzin.me via read-only IMAP with local LLM (`qwen3vl-4b`); plus `tripit-poll-flights` + `tripit-run-reminders`. App secrets in Vault `secret/tripit`. | tripit | +| tripit | Self-hosted TripIt-clone travel-itinerary PWA (FastAPI + SvelteKit SPA, same-origin). CNPG (`tripit` db, Vault static role `pg-tripit`) + RWX NFS trip-doc vault (`/srv/nfs/tripit-documents`) + RWO `proxmox-lvm-encrypted` personal-document vault `tripit-personal-documents` (passports/IDs — AES-256-GCM app-layer envelope, master key `DOCUMENT_ENCRYPTION_KEY` in `secret/tripit`). `auth=required` (Authentik forward-auth, reads `X-authentik-email`); second `auth=none` ingress on `/api/calendar` for HMAC-token-gated `.ics` feed. Email-ingest CronJob `tripit-ingest-mail` (`*/30`) parses me@viktorbarzin.me via read-only IMAP with local LLM (`qwen3vl-4b`); plus `tripit-poll-flights` + `tripit-run-reminders`. App secrets in Vault `secret/tripit`. | tripit | ## Cloudflare Domains diff --git a/stacks/tripit/main.tf b/stacks/tripit/main.tf index ccdbd5ce..b81f613a 100644 --- a/stacks/tripit/main.tf +++ b/stacks/tripit/main.tf @@ -29,14 +29,15 @@ locals { # at the RWX NFS PVC — the app's default ./var is not writable by the # non-root user. app_env = { - AUTH_MODE = "forwardauth" - SERVE_FRONTEND_DIR = "/app/frontend_build" - STORAGE_DIR = "/data/documents" - FLIGHT_PROVIDER = "fake" - WEATHER_PROVIDER = "openmeteo" - PUSH_PROVIDER = "webpush" - LLM_MODE = "fake" - MAIL_INGEST_ENABLED = "false" + AUTH_MODE = "forwardauth" + SERVE_FRONTEND_DIR = "/app/frontend_build" + STORAGE_DIR = "/data/documents" + PERSONAL_STORAGE_DIR = "/data/personal-documents" + FLIGHT_PROVIDER = "fake" + WEATHER_PROVIDER = "openmeteo" + PUSH_PROVIDER = "webpush" + LLM_MODE = "fake" + MAIL_INGEST_ENABLED = "false" # Outbound mail for linked-email verification — submitted via the cluster # mailserver as spam@ (which relays out via Brevo). SMTP_PASSWORD comes from # tripit-secrets (mapped to the existing PLANS_IMAP_PASSWORD). PUBLIC_BASE_URL @@ -106,6 +107,7 @@ resource "kubernetes_manifest" "external_secret" { { secretKey = "VAPID_PRIVATE_KEY", remoteRef = { key = "tripit", property = "VAPID_PRIVATE_KEY" } }, { secretKey = "VAPID_SUBJECT", remoteRef = { key = "tripit", property = "VAPID_SUBJECT" } }, { secretKey = "CALENDAR_TOKEN_SECRET", remoteRef = { key = "tripit", property = "CALENDAR_TOKEN_SECRET" } }, + { secretKey = "DOCUMENT_ENCRYPTION_KEY", remoteRef = { key = "tripit", property = "DOCUMENT_ENCRYPTION_KEY" } }, { secretKey = "IMAP_PASSWORD", remoteRef = { key = "tripit", property = "IMAP_PASSWORD" } }, # spam@viktorbarzin.me password — used only by the ingest-plans CronJob # (forward-to-parse via the @viktorbarzin.me -> spam@ catch-all). @@ -183,6 +185,37 @@ module "documents_nfs" { access_modes = ["ReadWriteMany"] } +# RWO encrypted PVC for the PERSONAL document vault (passports, IDs). Separate +# from the RWX NFS trip-doc store: owner-private identity docs get LUKS2 at-rest +# (proxmox-lvm-encrypted) UNDER the app-layer AES-256-GCM ciphertext (defense in +# depth). RWO is safe because the Deployment is replicas=1 + Recreate (single +# writer); only the app container mounts it, not the worker CronJobs. +resource "kubernetes_persistent_volume_claim" "personal_documents" { + wait_until_bound = false + metadata { + name = "tripit-personal-documents" + namespace = kubernetes_namespace.tripit.metadata[0].name + annotations = { + "resize.topolvm.io/threshold" = "10%" + "resize.topolvm.io/increase" = "100%" + "resize.topolvm.io/storage_limit" = "5Gi" + } + } + spec { + access_modes = ["ReadWriteOnce"] + storage_class_name = "proxmox-lvm-encrypted" + resources { + requests = { + storage = "2Gi" + } + } + } + lifecycle { + # Autoresizer grows requests.storage up to storage_limit; PVCs can't shrink. + ignore_changes = [spec[0].resources[0].requests] + } +} + resource "kubernetes_deployment" "tripit" { metadata { name = "tripit" @@ -235,6 +268,28 @@ resource "kubernetes_deployment" "tripit" { } } + # The proxmox-lvm-encrypted block PVC mounts root-owned; the app runs as + # uid 10001. chown it so the non-root app can write. Scoped to THIS block + # volume only (a pod-level fsGroup would also recursively chown the NFS + # doc vault, whose CSI fsGroupPolicy=File — risky on a root-squashed + # export). The NFS vault handles its own perms and is left untouched. + init_container { + name = "chown-personal-documents" + image = "busybox:1.37" + command = ["sh", "-c", "chown -R 10001:999 /data/personal-documents"] + security_context { + run_as_user = 0 + } + volume_mount { + name = "personal-documents" + mount_path = "/data/personal-documents" + } + resources { + requests = { cpu = "10m", memory = "16Mi" } + limits = { memory = "32Mi" } + } + } + container { name = "tripit" image = local.image @@ -263,6 +318,11 @@ resource "kubernetes_deployment" "tripit" { mount_path = "/data/documents" } + volume_mount { + name = "personal-documents" + mount_path = "/data/personal-documents" + } + readiness_probe { http_get { path = "/healthz" @@ -292,6 +352,13 @@ resource "kubernetes_deployment" "tripit" { claim_name = module.documents_nfs.claim_name } } + + volume { + name = "personal-documents" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.personal_documents.metadata[0].name + } + } } } } @@ -373,9 +440,9 @@ locals { # spam@'s password via imap_password_key (secret/tripit PLANS_IMAP_PASSWORD), # because env_from otherwise injects the Gmail app-password. ingest-plans = { - schedule = "*/15 * * * *" - command = ["python", "-m", "tripit_api", "ingest-mail"] - suspend = false + schedule = "*/15 * * * *" + command = ["python", "-m", "tripit_api", "ingest-mail"] + suspend = false imap_pw_secret_key = "PLANS_IMAP_PASSWORD" extra_env = { LLM_MODE = "llamacpp" @@ -401,10 +468,10 @@ locals { # authorization 403s the in-cluster *.svc Host header, so we reach it through # the ingress (auth=none, api_key-gated) instead. transport-nudge = { - schedule = "0 8 * * *" - timezone = "Europe/London" - command = ["python", "-m", "tripit_api", "run-transport-nudge"] - suspend = false + schedule = "0 8 * * *" + timezone = "Europe/London" + command = ["python", "-m", "tripit_api", "run-transport-nudge"] + suspend = false extra_env = { NUDGES_ENABLED = "true" SLACK_PROVIDER = "slack" @@ -413,10 +480,10 @@ locals { } } weather-brief = { - schedule = "0 21 * * *" - timezone = "Europe/London" - command = ["python", "-m", "tripit_api", "run-weather-brief"] - suspend = false + schedule = "0 21 * * *" + timezone = "Europe/London" + command = ["python", "-m", "tripit_api", "run-weather-brief"] + suspend = false extra_env = { NUDGES_ENABLED = "true" SLACK_PROVIDER = "slack"