diff --git a/.env.example b/.env.example index e4e974d..953627c 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,10 @@ GITHUB_VTC_PACKAGE_NAME=vtc # Passkey / WebAuthn # Dev: RPID=localhost, ORIGINS=http://localhost:5173 # Prod: RPID=vtafarm.firstperson.dev, ORIGINS=https://vtafarm.firstperson.dev +# Extra browser origins allowed to call the API. The Vite dev server's ports +# are always allowed, so this stays empty in development. +CORS_ALLOWED_ORIGINS= + WEBAUTHN_RP_ID=localhost WEBAUTHN_RP_ORIGINS=http://localhost:5173 WEBAUTHN_RP_DISPLAY_NAME="VTA Farm" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3521263..7c5377c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -47,6 +47,8 @@ jobs: KUBECONFIG_PATH: ${{ secrets.KUBECONFIG_PATH }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} INGRESS_HOST: vtafarm-api.firstperson.dev + FRONTEND_HOST: vtafarm.firstperson.dev + CLUSTER_DOMAIN: firstperson.dev steps: - uses: actions/checkout@v6 diff --git a/Makefile b/Makefile index fc0ec97..6ae5c34 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ TAG ?= $(shell git rev-parse --short HEAD) NAMESPACE ?= default DEPLOY_ENV ?= production INGRESS_HOST ?= +FRONTEND_HOST ?= +CLUSTER_DOMAIN ?= # ─── Dev cluster ────────────────────────────────────────────────────────────── # The database is shared and lives here — see docs/shared-dev-database.md. @@ -132,5 +134,7 @@ deploy: --set image.tag=$(TAG) \ --set app.env=$(DEPLOY_ENV) \ --set ingress.host=$(INGRESS_HOST) \ + --set frontendHost=$(FRONTEND_HOST) \ + --set cluster.domain=$(CLUSTER_DOMAIN) \ --install --atomic --timeout=10m \ --namespace=$(NAMESPACE) diff --git a/helm/vtafarm-api/templates/_helpers.tpl b/helm/vtafarm-api/templates/_helpers.tpl index d37a11d..7520581 100644 --- a/helm/vtafarm-api/templates/_helpers.tpl +++ b/helm/vtafarm-api/templates/_helpers.tpl @@ -1,3 +1,11 @@ {{- define "app.labels" -}} app: {{ .Values.name }} {{- end }} + +{{- define "app.frontendHost" -}} +{{ required "frontendHost is required - WebAuthn and CORS both derive from it" .Values.frontendHost }} +{{- end }} + +{{- define "app.frontendOrigin" -}} +https://{{ include "app.frontendHost" . }} +{{- end }} diff --git a/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml b/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml index 0db67a2..1e4eb0e 100644 --- a/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml +++ b/helm/vtafarm-api/templates/vtafarm-api/configmap.yaml @@ -21,7 +21,7 @@ data: VAULT_K8S_AUTH_MOUNT: {{ .Values.vault.k8sAuthMount | quote }} VAULT_APPROLE_MOUNT: {{ .Values.vault.appRoleMount | quote }} VAULT_SKIP_VERIFY: {{ .Values.vault.skipVerify | quote }} - CLUSTER_DOMAIN: {{ .Values.cluster.domain | quote }} + CLUSTER_DOMAIN: {{ required "cluster.domain is required - it is the zone tenant hostnames are created under" .Values.cluster.domain | quote }} MONITOR_CPU_PCT: {{ .Values.monitor.cpuPct | quote }} MONITOR_MEM_PCT: {{ .Values.monitor.memPct | quote }} MONITOR_STORAGE_PCT: {{ .Values.monitor.storagePct | quote }} @@ -32,6 +32,7 @@ data: GITHUB_PACKAGE_NAME: {{ .Values.github.packageName | quote }} GITHUB_MEDIATOR_PACKAGE_NAME: {{ .Values.github.mediatorPackageName | quote }} GITHUB_DID_HOSTING_DAEMON_PACKAGE_NAME: {{ .Values.github.didHostingDaemonPackageName | quote }} - WEBAUTHN_RP_ID: {{ .Values.webauthn.rpID | quote }} - WEBAUTHN_RP_ORIGINS: {{ .Values.webauthn.rpOrigins | quote }} + WEBAUTHN_RP_ID: {{ .Values.webauthn.rpID | default (include "app.frontendHost" .) | quote }} + WEBAUTHN_RP_ORIGINS: {{ .Values.webauthn.rpOrigins | default (include "app.frontendOrigin" .) | quote }} WEBAUTHN_RP_DISPLAY_NAME: {{ .Values.webauthn.rpDisplayName | quote }} + CORS_ALLOWED_ORIGINS: {{ .Values.cors.allowedOrigins | default (include "app.frontendOrigin" .) | quote }} diff --git a/helm/vtafarm-api/values.yaml b/helm/vtafarm-api/values.yaml index d5c24b8..e72ab0d 100644 --- a/helm/vtafarm-api/values.yaml +++ b/helm/vtafarm-api/values.yaml @@ -57,8 +57,13 @@ vault: skipVerify: "true" existingSecret: "vtafarm-api-vault" +# The frontend's public hostname. WebAuthn and CORS both derive from it, since +# the browser only ever sees the app on one origin. +frontendHost: "" + cluster: - domain: "firstperson.dev" + # Zone the tenant hostnames are created under - vta-alice.. + domain: "" # Monitor endpoints (/api/v1/monitor/*) polled by UptimeRobot. # MONITOR_TOKEN lives in the vtafarm-api-secrets Secret — see k8s/secret.yaml.example. @@ -77,9 +82,17 @@ github: didHostingDaemonPackageName: did-hosting-daemon webauthn: - rpID: "vtafarm.firstperson.dev" - rpOrigins: "https://vtafarm.firstperson.dev" rpDisplayName: "VTA Farm" + # Both default to frontendHost. Override rpID only to scope credentials to a + # parent domain: a valid RP ID is the origin's domain or a registrable suffix + # of it, never anything else. + rpID: "" + rpOrigins: "" + +# Browser origins allowed to call the API with credentials. Defaults to the +# frontend's; the localhost dev servers are always allowed on top of it. +cors: + allowedOrigins: "" # No CPU limit: the request gives fair-share under contention; a CFS quota # would throttle bursts on an idle node. Memory keeps a hard limit. diff --git a/internal/config/config.go b/internal/config/config.go index 7c4d886..7962743 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,10 @@ type Config struct { // database, where every API would resume the same rows. // See docs/shared-dev-database.md. OrchestratorResume bool + // CORSAllowedOrigins are the deployment's own browser origins - normally + // just the frontend's. The localhost dev servers are allowed unconditionally + // alongside these, see internal/router. + CORSAllowedOrigins []string // MaxStackConnections caps how many vta_only sessions may connect to one // shared full_stack. 0 disables the cap. // @@ -175,6 +179,7 @@ func Load() *Config { ACMEClusterIssuer: getEnv("ACME_CLUSTER_ISSUER", DefaultACMEIssuer), OrchestratorResume: getEnvBool("ORCHESTRATOR_RESUME", true), + CORSAllowedOrigins: splitComma(getEnv("CORS_ALLOWED_ORIGINS", "")), MaxStackConnections: getEnvInt("MAX_STACK_CONNECTIONS", 10), DB: DBConfig{ Host: getEnv("DB_HOST", "localhost"), diff --git a/internal/router/router.go b/internal/router/router.go index 32224a5..ad74b19 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -38,8 +38,17 @@ func Setup( ) *gin.Engine { r := gin.Default() + // The Vite dev server's ports stay hardcoded - they are a local debugging + // convenience that costs nothing to carry, and no deployment would think to + // configure them. Everything else is the deployment's own frontend origin, + // which differs per install. + allowOrigins := append( + []string{"http://localhost:5173", "http://localhost:5174", "http://localhost:5175"}, + cfg.CORSAllowedOrigins..., + ) + r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"https://vtafarm.firstperson.dev", "http://localhost:5173", "http://localhost:5174", "http://localhost:5175"}, + AllowOrigins: allowOrigins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Authorization", "Content-Type"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b7873d6..6ff351d 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,7 +2,8 @@ set -e # Required env vars (set by GitHub Actions): -# SSH_PRIVATE_KEY, SERVER_IP, KUBECONFIG_PATH, DOCKER_USERNAME +# SSH_PRIVATE_KEY, SERVER_IP, KUBECONFIG_PATH, DOCKER_USERNAME, +# INGRESS_HOST, FRONTEND_HOST, CLUSTER_DOMAIN # TAG defaults to the short git SHA via the Makefile (git rev-parse --short HEAD). # Pre-requisite: Secret "vtafarm-api-postgresql" must exist in the cluster namespace. # See k8s/postgresql-secret.yaml — apply once manually before first deploy. @@ -38,7 +39,9 @@ curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash echo "Deploying vtafarm..." make deploy \ DOCKER_USERNAME="$DOCKER_USERNAME" \ - INGRESS_HOST="$INGRESS_HOST" + INGRESS_HOST="$INGRESS_HOST" \ + FRONTEND_HOST="$FRONTEND_HOST" \ + CLUSTER_DOMAIN="$CLUSTER_DOMAIN" # ── Cleanup ─────────────────────────────────────────────────────────────────── eval $(ssh-agent -k)