From c311a6a3c9e12abf6f7a48a772534294b5e0b25b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 4 Jul 2026 10:01:53 +0000 Subject: [PATCH] tasks: public ingress carve-out for PWA icons; adopt orphaned stack state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS Safari's Add to Dock (and iOS/Android home-screen installs) fetch the app icon and web manifest without any session cookies, so the Authentik forward-auth 302 on tasks.viktorbarzin.me made Safari fall back to a letter monogram instead of the real icon. Viktor asked for an ingress carve-out so exactly these five static PWA assets are publicly fetchable: /apple-touch-icon.png, /favicon.png, /pwa-192x192.png, /pwa-512x512.png, /manifest.webmanifest. A second ingress_factory instance (auth=none, dns_type=none, same host) routes only those paths straight to the tasks service; the SPA shell and /api stay behind Authentik exactly as before. The new carve-out is also registered in the Authentik walling-off probe so a future regression (anything 302-ing these paths to Authentik again) alarms, and the service catalog entry records the exception. stacks/tasks/imports.tf adopts the live tasks resources into Terraform state first: the stack's first-ever apply (pipeline 477, 2026-07-03) died mid-apply after creating the resources but before the pg state write, leaving tasks.states empty — without the import blocks this (and every future) tasks apply would create-fail with 'already exists'. Same pattern as the monitoring alert-digest adoption. Co-Authored-By: Claude Fable 5 --- .claude/reference/service-catalog.md | 2 +- .../monitoring/authentik_walloff_probe.tf | 4 ++ stacks/tasks/imports.tf | 53 +++++++++++++++++++ stacks/tasks/main.tf | 34 ++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 stacks/tasks/imports.tf diff --git a/.claude/reference/service-catalog.md b/.claude/reference/service-catalog.md index 076c52d9..7c84dd3b 100644 --- a/.claude/reference/service-catalog.md +++ b/.claude/reference/service-catalog.md @@ -121,7 +121,7 @@ | 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 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-plans` (`*/15`) is the SOLE inbound path — forward a booking to plans@viktorbarzin.me (catch-all → spam@), polled read-only and routed ONLY to a registered user / verified linked address (no default-owner fallback; strangers ignored), parsed by local LLM (`qwen3vl-4b`), and the sender is emailed the outcome (Added to trip / Couldn't import). Plus `tripit-poll-flights`, `tripit-run-reminders`, `tripit-transport-nudge`, `tripit-weather-brief`. (The old Gmail-scrape `tripit-ingest-mail` CronJob was removed 2026-06-05.) App secrets in Vault `secret/tripit`. | tripit | -| tasks | Reminders-style tasks PWA over Nextcloud CalDAV (FastAPI + SvelteKit SPA same-origin, single container; code `~/code/tasks`, design `tasks/docs/2026-07-03-tasks-pwa-design.md`). Nextcloud stays the source of truth (VTODOs); the app is the front-end Apple Reminders stopped being. CNPG (`tasks` db, Vault static role `pg-tasks`) stores Connected Accounts — per-user Nextcloud app passwords Fernet-encrypted with `fernet_key` from `secret/tasks`. `auth=required` (Authentik forward-auth; identity = `X-authentik-username`, NO app-level login — `DEV_USER` must never be set in prod) at tasks.viktorbarzin.me (proxied). NetworkPolicy `tasks-ingress` (SEC-1) restricts pod ingress to traefik + monitoring namespaces so the trusted header can't be spoofed pod-to-pod. GHA → public ghcr `tasks` → Woodpecker deploy (ADR-0002). | tasks | +| tasks | Reminders-style tasks PWA over Nextcloud CalDAV (FastAPI + SvelteKit SPA same-origin, single container; code `~/code/tasks`, design `tasks/docs/2026-07-03-tasks-pwa-design.md`). Nextcloud stays the source of truth (VTODOs); the app is the front-end Apple Reminders stopped being. CNPG (`tasks` db, Vault static role `pg-tasks`) stores Connected Accounts — per-user Nextcloud app passwords Fernet-encrypted with `fernet_key` from `secret/tasks`. `auth=required` (Authentik forward-auth; identity = `X-authentik-username`, NO app-level login — `DEV_USER` must never be set in prod) at tasks.viktorbarzin.me (proxied). Exception: the five PWA icon/manifest files (`/apple-touch-icon.png`, `/favicon.png`, `/pwa-192x192.png`, `/pwa-512x512.png`, `/manifest.webmanifest`) are a path-scoped `auth=none` carve-out (`module.ingress_icons`) so cookie-less OS icon fetchers (macOS Safari Add-to-Dock, mobile home-screen installs) get the real icon instead of the Authentik 302; guarded by the `tasks-icons` walloff-probe target. NetworkPolicy `tasks-ingress` (SEC-1) restricts pod ingress to traefik + monitoring namespaces so the trusted header can't be spoofed pod-to-pod. GHA → public ghcr `tasks` → Woodpecker deploy (ADR-0002). | tasks | | stem95su | STEM educational platform for **95. СУ „Проф. Иван Шишманов"** (Sofia school) at stem95su.viktorbarzin.me — **a Valia site on Cloudflare Pages since 2026-07-03** (ADR-0018): registry entry in `stacks/valia-sites`, synced from Drive folder "claude" every 10 min, deploy-on-change. The old in-cluster stack (nginx off PVE NFS + per-site rclone CronJob) is RETIRED — stacks/stem95su is a tombstone; `secret/stem95su` superseded by `secret/valia-sites`; `stem_video.mp4` was compressed 42.9→21.4MB (25MB Pages cap) with Viktor's OK. See docs/runbooks/valia-sites.md. | — | | valia-sites | **Valia-site registry + sync** (ADR-0018): all sites authored by Valia serve OFF-INFRA on Cloudflare Pages (`bridge` + `stem95su` live). One map entry in `stacks/valia-sites/main.tf` per site fans out Pages project + custom domain + public CNAME + internal split-horizon CNAME (ConfigMap `valia-sites-dns` → technitium sync, declarative incl. removal). CronJob `valia-sites-sync` (`*/10`, image ghcr `valia-sites-sync`) mirrors each Drive Content folder (rclone `drive.readonly`, stem95su-style guards + 25MB Pages-cap guard) and wrangler-deploys ONLY on manifest change (free-tier deploy cap). Secrets `secret/valia-sites` (shared rclone conf + SCOPED CF Pages token — Global API Key never in pods). Failed-Job-only visibility by choice. Runbook: docs/runbooks/valia-sites.md. | valia-sites | | trek | **TRIAL (2026-06-05)** — self-hosted group-trip planner (upstream [TREK](https://github.com/mauriceboe/TREK), `mauriceboe/trek:3.0.22`, AGPL-3.0). Solo evaluation behind Authentik forward-auth (`auth=required`) before deciding build-vs-adopt; covers collaborative trip planning + accommodation records + activities + per-person budget splitting on free OpenStreetMap (no paid maps key). SQLite + uploads on `proxmox-lvm-encrypted` (`trek-data-encrypted` 2Gi, `trek-uploads-encrypted` 5Gi). For the trial only: `ENCRYPTION_KEY` is TREK-auto-generated onto the data PVC and the bootstrap admin (`admin@trek.local`) is printed to pod logs — NO Vault/ESO wiring (graduation TODO: move key to `secret/trek` + ESO, add an app-level SQLite backup CronJob since host file-backup can't read the LUKS PVC, wire TREK↔Authentik OIDC). Pinned image, TF-managed (no CI/Keel). Availability-poll companion (Rallly) deferred. Teardown: `tg destroy` in `stacks/trek`. | trek | diff --git a/stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf b/stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf index 50928cca..e72bb273 100644 --- a/stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf +++ b/stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf @@ -60,6 +60,10 @@ locals { # t3 dispatch probe surface (auth="none" path carve-out on /probe): WS echo # + healthz for the t3-probe drop-attribution client (stacks/t3code). "t3-probe-ws" = "https://t3.viktorbarzin.me/probe/healthz" + # tasks PWA icons + manifest (auth="none" path carve-out, stacks/tasks + # module.ingress_icons): macOS/iOS/Android icon fetchers carry no session + # cookies, so an Authentik 302 here breaks Add-to-Dock icons. + "tasks-icons" = "https://tasks.viktorbarzin.me/apple-touch-icon.png" # NOTE: openclaw task-webhook (auth="none") is intentionally NOT probed — it # has no public DNS record (NXDOMAIN, external_monitor=false), so there is no # externally GET-able URL to probe. Its carve-out is internal-only. diff --git a/stacks/tasks/imports.tf b/stacks/tasks/imports.tf new file mode 100644 index 00000000..9d86afda --- /dev/null +++ b/stacks/tasks/imports.tf @@ -0,0 +1,53 @@ +# One-shot adoption of the live tasks-stack resources that exist in-cluster but +# were never persisted to Terraform state: pipeline 477 (2026-07-03, the stack's +# first apply) died mid-`[tasks] apply` — after creating the resources, before +# the pg backend write — so `tasks.states` stayed empty and every later apply +# would create-fail with `namespaces "tasks" already exists` (same class as the +# monitoring alert-digest adoption in stacks/monitoring/imports.tf). Importing +# reconciles them into state so `terraform apply` UPDATES instead of failing to +# create. These blocks are idempotent (a no-op once the resources are in state) +# and may be removed after the next green apply. Defs: main.tf. +# (module.ingress_icons is deliberately NOT here — it does not exist live yet; +# the same apply creates it.) + +import { + to = kubernetes_namespace.tasks + id = "tasks" +} + +import { + to = kubernetes_manifest.external_secret + id = "apiVersion=external-secrets.io/v1,kind=ExternalSecret,namespace=tasks,name=tasks-secrets" +} + +import { + to = kubernetes_manifest.db_external_secret + id = "apiVersion=external-secrets.io/v1,kind=ExternalSecret,namespace=tasks,name=tasks-db-creds" +} + +import { + to = kubernetes_deployment.tasks + id = "tasks/tasks" +} + +import { + to = kubernetes_service.tasks + id = "tasks/tasks" +} + +import { + to = kubernetes_network_policy_v1.tasks_ingress + id = "tasks/tasks-ingress" +} + +import { + to = module.ingress.kubernetes_ingress_v1.proxied-ingress + id = "tasks/tasks" +} + +# Cloudflare record ID looked up via the API (zone fd2c5dd4… / record for +# tasks.viktorbarzin.me, CNAME → the cfargotunnel target, proxied). +import { + to = module.ingress.cloudflare_record.proxied[0] + id = "fd2c5dd4efe8fe38958944e74d0ced6d/a8e6901a074c5255d09700d93eaaf705" +} diff --git a/stacks/tasks/main.tf b/stacks/tasks/main.tf index e6b5c326..eed06ae5 100644 --- a/stacks/tasks/main.tf +++ b/stacks/tasks/main.tf @@ -293,6 +293,40 @@ module "ingress" { tls_secret_name = var.tls_secret_name } +# Carve-out for the PWA icon assets + web manifest. macOS Safari's +# "Add to Dock" (and every other OS icon fetcher: iOS Add-to-Home-Screen, +# Android install prompt) fetches these in a cookie-less context — behind +# forward-auth it got the Authentik 302 and fell back to a letter monogram. +# Traefik prioritises these longer path prefixes over the main "/" router, +# so ONLY these five static files bypass Authentik; the SPA shell and /api +# stay gated by the main ingress above (and the app itself 401s /api +# without the identity header). Guarded against regression by the +# tasks-icons entry in the Authentik walling-off probe +# (stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf). +module "ingress_icons" { + source = "../../modules/kubernetes/ingress_factory" + # auth = "none": public static icons + manifest, no user data; required for + # OS icon fetchers (Safari Add-to-Dock etc.) that carry no session and + # cannot complete the Authentik redirect dance. + auth = "none" + namespace = kubernetes_namespace.tasks.metadata[0].name + name = "tasks-icons" + service_name = kubernetes_service.tasks.metadata[0].name + port = 8000 + ingress_path = [ + "/apple-touch-icon.png", + "/favicon.png", + "/pwa-192x192.png", + "/pwa-512x512.png", + "/manifest.webmanifest", + ] + full_host = "tasks.viktorbarzin.me" # MUST match the main ingress host; otherwise the factory derives tasks-icons.viktorbarzin.me and the carve-out never matches. + dns_type = "none" # host record already owned by the main tasks ingress + tls_secret_name = var.tls_secret_name + anti_ai_scraping = false # Five static icons + a manifest; nothing for scrapers to mine. + homepage_enabled = false # path carve-out, not its own dashboard tile +} + # --- NetworkPolicy: scoped pod ingress (security-review finding SEC-1). --- # The app trusts X-authentik-username unconditionally, so its ENTIRE auth # model depends on requests only ever arriving through Traefik (where the