diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50ab7dd..bc38d68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,9 @@ jobs: # request brings in, with rules kept in the repository. The image is pinned # by digest; it marks every directory git-safe itself, so the runner's uid # owning the checkout is not a problem. + # --verbose prints each finding's file, line, rule and commit (the value stays + # REDACTED), so a red run says where to look; accepted false positives are + # listed by fingerprint in .gitleaksignore. gitleaks: runs-on: ubuntu-latest timeout-minutes: 10 @@ -231,7 +234,7 @@ jobs: run: >- docker run --rm -v "$PWD":/repo:ro zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f - git --redact --no-banner --exit-code 1 /repo + git --redact --verbose --no-banner --exit-code 1 /repo # The third-party images the manifests and docker-compose run (NATS, Redis, # Postgres, monitoring, tracing). The image scan above covers only the @@ -310,6 +313,9 @@ jobs: curl -fsSLo kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" echo "${KUBECONFORM_SHA256} kubeconform.tar.gz" | sha256sum -c - tar xzf kubeconform.tar.gz kubeconform + # The local overlay reads its secrets from a gitignored file; generate + # throwaway values so it renders here too. + scripts/k8s/local-secrets.sh # Against the Kubernetes version k3s runs, overlay by overlay. for overlay in local prod; do echo "== overlays/${overlay}" diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..54d68cb --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Accepted gitleaks findings, by fingerprint (commit:file:rule:line). gitleaks +# scans every commit, so a false positive in history stays until it is listed +# here; rewriting pushed history is not an option. +# +# Test fixture, not a secret: strongSecret in pkg/config/config_test.go was the +# literal "0123456789abcdef" repeated four times, which the generic-api-key rule +# flags for its name and entropy. The test now builds the value at run time. +c255f6a685bc737b0be8ab8b85892314d7fd32e3:pkg/config/config_test.go:generic-api-key:103 diff --git a/Makefile b/Makefile index 4e1a89b..db1b221 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build test test-integration lint cover up down k8s-build k8s-context k8s-apply k8s-delete k6-leaderboard k6-matchmaking k6-websocket +.PHONY: help build test test-integration lint cover up down k8s-build k8s-secrets k8s-context k8s-apply k8s-delete k6-leaderboard k6-matchmaking k6-websocket # Every directory under cmd/, as CI's image matrix: the hand-written list had # lost outbox-relay, so k8s-build skipped an image the cluster needs. @@ -48,7 +48,10 @@ k8s-context: @test "$$(kubectl config current-context)" = "$(K3D_CONTEXT)" || \ { echo "kubectl context is '$$(kubectl config current-context)', not $(K3D_CONTEXT); refusing" >&2; exit 1; } -k8s-apply: k8s-context ## Deploy the local overlay to the k3d cluster +k8s-secrets: ## Generate the local overlay's secrets (.env.local, gitignored) + @scripts/k8s/local-secrets.sh + +k8s-apply: k8s-context k8s-secrets ## Deploy the local overlay to the k3d cluster kubectl apply -k deployments/k8s/overlays/local k8s-delete: k8s-context ## Tear down the local overlay diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go index ed0e535..97a2de8 100644 --- a/cmd/gateway/main.go +++ b/cmd/gateway/main.go @@ -32,6 +32,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedJWT, config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/leaderboard/main.go b/cmd/leaderboard/main.go index 087f011..3690be2 100644 --- a/cmd/leaderboard/main.go +++ b/cmd/leaderboard/main.go @@ -30,6 +30,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/matchmaking/main.go b/cmd/matchmaking/main.go index e8a6127..3dbe652 100644 --- a/cmd/matchmaking/main.go +++ b/cmd/matchmaking/main.go @@ -31,6 +31,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/outbox-relay/main.go b/cmd/outbox-relay/main.go index d7bf565..09f2196 100644 --- a/cmd/outbox-relay/main.go +++ b/cmd/outbox-relay/main.go @@ -36,6 +36,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedPostgres); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/player/main.go b/cmd/player/main.go index 48cfa3f..695e9eb 100644 --- a/cmd/player/main.go +++ b/cmd/player/main.go @@ -34,6 +34,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedJWT, config.NeedPostgres, config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/presence/main.go b/cmd/presence/main.go index 8267d60..6044a21 100644 --- a/cmd/presence/main.go +++ b/cmd/presence/main.go @@ -32,6 +32,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/cmd/websocket/main.go b/cmd/websocket/main.go index e55b0a2..a2ea363 100644 --- a/cmd/websocket/main.go +++ b/cmd/websocket/main.go @@ -39,6 +39,11 @@ func run() int { Thereafter: cfg.LogSampleThereafter, }) defer func() { _ = log.Sync() }() + // In production, refuse to start with a secret that is unset, short or a + // known default rather than run with one anyone can read in the repository. + if err := cfg.Validate(config.NeedJWT, config.NeedRedis); err != nil { + log.Fatal("refusing to start with weak secrets", zap.Error(err)) + } shutdownTracing := tracing.MustInit(context.Background(), tracing.Config{ Enabled: cfg.OTelEnabled, diff --git a/deployments/k8s/base/redis.yaml b/deployments/k8s/base/redis.yaml index 66678e9..04ae134 100644 --- a/deployments/k8s/base/redis.yaml +++ b/deployments/k8s/base/redis.yaml @@ -57,7 +57,19 @@ spec: image: redis:7.4.11-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499 # AOF: every write is appended and fsynced once a second, so a restart # loses at most about a second of writes instead of everything. - args: ["redis-server", "--appendonly", "yes"] + # requirepass: without it any pod in the cluster read and changed + # every session, queue and leaderboard (measured from the gateway + # pod: PING +PONG, DBSIZE answered). The value comes from the Secret; + # the services already send it (REDIS_PASSWORD through envFrom). + args: ["redis-server", "--appendonly", "yes", "--requirepass", "$(REDIS_PASSWORD)"] + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: { name: gamemesh-secrets, key: REDIS_PASSWORD } + # redis-cli in the probes authenticates with it. + - name: REDISCLI_AUTH + valueFrom: + secretKeyRef: { name: gamemesh-secrets, key: REDIS_PASSWORD } ports: - containerPort: 6379 startupProbe: diff --git a/deployments/k8s/components/monitoring/monitoring.yaml b/deployments/k8s/components/monitoring/monitoring.yaml index 299fd6b..ad55c3f 100644 --- a/deployments/k8s/components/monitoring/monitoring.yaml +++ b/deployments/k8s/components/monitoring/monitoring.yaml @@ -234,8 +234,11 @@ spec: env: - name: GF_SECURITY_ADMIN_USER value: admin + # From the overlay's Secret, not written here: with value admin + # anyone who reached Grafana logged in as admin with admin/admin. - name: GF_SECURITY_ADMIN_PASSWORD - value: admin + valueFrom: + secretKeyRef: { name: gamemesh-secrets, key: GRAFANA_ADMIN_PASSWORD } securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deployments/k8s/overlays/local/kustomization.yaml b/deployments/k8s/overlays/local/kustomization.yaml index e162f6f..b3840af 100644 --- a/deployments/k8s/overlays/local/kustomization.yaml +++ b/deployments/k8s/overlays/local/kustomization.yaml @@ -1,5 +1,5 @@ -# The local k3d cluster: images built and imported on this machine, dev -# secrets, monitoring included, the base's replica counts (two websocket +# The local k3d cluster: images built and imported on this machine, secrets +# generated on this machine, monitoring included, the base's replica counts (two websocket # replicas exercise the event fan-out). # # kubectl apply -k deployments/k8s/overlays/local @@ -30,10 +30,13 @@ images: newTag: dev - name: gamemesh/presence newTag: dev +# Values from .env.local, which is gitignored and written by +# make k8s-secrets (random, openssl rand -hex 32): no secret value is in the +# repository, not even a development one. A render fails until it exists. secretGenerator: - name: gamemesh-secrets envs: - - secrets.env + - .env.local patches: # Traefik's pods are the gateway's only direct clients; k3s's default pod # CIDR. Without it the gateway takes Traefik's address for the client's and diff --git a/deployments/k8s/overlays/local/secrets.env b/deployments/k8s/overlays/local/secrets.env deleted file mode 100644 index 1a2565b..0000000 --- a/deployments/k8s/overlays/local/secrets.env +++ /dev/null @@ -1,8 +0,0 @@ -# DEV-ONLY values for the local k3d cluster, the ones the Minikube manifests -# shipped with. Generated into the Secret gamemesh-secrets- by -# kustomization.yaml. Production has no Secret in the repository: it is -# created in the cluster with values from openssl rand -hex 32 (A9). -JWT_SECRET=minikube-dev-secret-change-me -POSTGRES_USER=gamemesh -POSTGRES_PASSWORD=gamemesh-dev-password -POSTGRES_DB=gamemesh diff --git a/deployments/k8s/overlays/prod/kustomization.yaml b/deployments/k8s/overlays/prod/kustomization.yaml index c43c009..b013e58 100644 --- a/deployments/k8s/overlays/prod/kustomization.yaml +++ b/deployments/k8s/overlays/prod/kustomization.yaml @@ -2,10 +2,15 @@ # # kubectl apply -k deployments/k8s/overlays/prod (only from CI, step D) # -# No Secret is generated here: the cluster must already hold gamemesh-secrets -# (JWT_SECRET, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB), created there -# with values from openssl rand -hex 32 (A9). Monitoring stays out (finding -# #68). +# No Secret is generated here: the cluster must already hold gamemesh-secrets, +# created there and never committed (docs/deployment.md, "Production +# secrets"), with these keys: +# JWT_SECRET, POSTGRES_PASSWORD, REDIS_PASSWORD openssl rand -hex 32 each +# POSTGRES_USER, POSTGRES_DB gamemesh +# The services refuse to start in production when one they use is missing, +# shorter than 32 characters or a known default (config.Validate), so a +# missing or weak Secret stalls the rollout instead of running. Monitoring +# stays out (finding #68). apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: gamemesh diff --git a/docs/deployment-phase1-plan.md b/docs/deployment-phase1-plan.md index ce7d3dd..d71e713 100644 --- a/docs/deployment-phase1-plan.md +++ b/docs/deployment-phase1-plan.md @@ -1039,6 +1039,34 @@ Her adım şu yapıda: pod (ele geçirilmiş biri de) Redis'e tam erişir. Compose'daki gibi (`redis-server --requirepass`, A2a) şifre prod Secret'ından gelmeli; servisler `REDIS_PASSWORD`'ü zaten okuyor. +- **Kararlar (2026-10-19):** local secret'lar repo dışında ve rastgele (`make k8s-secrets`); + fail fast servis başına ihtiyaçla (`Validate(NeedJWT, NeedPostgres, NeedRedis)`); k8s'te + Redis `requirepass`; Grafana şifresi Secret'tan; prod Secret'ının cluster'da oluşturulması + B/D'de (değerler kullanıcının makinesinde `openssl` ile, hiçbir zaman bana ya da loglara + gelmeden). +- **Yapılanlar (`feat/production-secrets`, 2026-10-19), CI kanıtı bekleniyor:** + 1. `overlays/local/secrets.env` (commit'li dev değerleri) kalktı. Yerine gitignore'daki + `.env.local`, `scripts/k8s/local-secrets.sh` ile: eksik her anahtar `openssl rand -hex + 32` (64 karakter), izin 600, değer hiç basılmıyor, var olanlar korunuyor. Dosya yoksa + render hata veriyor; CI `manifests` job'u atılacak değerler üretiyor. k3d: yeni + Postgres şifresi eski volume'la `password authentication failed` (28P01) verdi, yeni + pod'lar takıldı, eski pod'lar trafiği almaya devam etti; volume yenilenince 19/19. + 2. Redis `--requirepass` (#56): önce gateway pod'undan şifresiz `PING` → `+PONG`, + `DBSIZE` yanıt verdi; sonra `-NOAUTH`, yanlış şifre `-WRONGPASS`; kayıt, oturum ve + kuyruk çalışıyor. Şifre süreç listesinde görünmüyor (Redis başlığı `redis-server + *:6379` olarak yeniden yazıyor). + 3. Grafana (#67): önce `admin/admin` 200; sonra 401, üretilen şifre 200. + 4. Fail fast (#40): `Config.Validate`; production'da eksik, 32 karakterden kısa ya da + bilinen varsayılan (koddaki ve `.env.example`'daki JWT) değerleri, ayarın adıyla ve + değeri yazmadan reddediyor. gateway imajı: varsayılanlarla iki hatayı birlikte + raporlayıp çıkıyor, güçlü değerlerle açılıyor. k3d: zayıf JWT ile gateway deploy'u → + yeni pod `CrashLoopBackOff`, eski 2 pod `1/1`, `/health` 200, rollout `undo` ile geri. + 5. Prod Secret sözleşmesi prod overlay'inde ve `docs/deployment.md`'de: `JWT_SECRET`, + `POSTGRES_PASSWORD`, `REDIS_PASSWORD` (her biri `openssl rand -hex 32`), + `POSTGRES_USER`, `POSTGRES_DB`. + - Not: eski dev değerleri git geçmişinde duruyor; hiçbir zaman local dışında kullanılmadı. +- **Bitti sayılır:** `git grep -i jwt_secret deployments/` sadece anahtarın adını veren bir + yorum satırı buluyor; `deployments/` altında hiçbir dosya bir secret'a değer atamıyor. ### A10. Prova: yerel k3s (k3d) üzerinde prod'a en yakın deploy @@ -1467,8 +1495,8 @@ manifest okunarak tespit edildi, henüz çalışan bir sistemde test edilmedi. | 36 | Toplam requests 1800m CPU, 2 vCPU'ya sığmıyor | M5 | Ölçüldü (hesap) | A8 (prod 10 pod, 950m/960Mi), B1 | | 37 | ConfigMap değişikliği pod'ları yeniden başlatmıyor | M5 | Ölçüldü (Docker analog) | A8 (çözüldü: `configMapGenerator`, hash'li ad) | | 38 | Ingress nginx'e özel; k3s'te sessizce yok sayılır | M5 | Kod | A8 (çözüldü: `traefik` sınıfı, k3d'de `/health` 200), C4 | -| 39 | Secret'lar repoda (`01-secrets.yaml`) | M5, M8 | Kod | A9 | -| 40 | **Uygulama production'da güvensiz secret ile sessizce açılıyor** | M8 | Kod | A9 | +| 39 | Secret'lar repoda (`01-secrets.yaml`) | M5, M8 | Kod | A9 (çözüldü: local `.env.local` gitignore'da ve rastgele; prod Secret repoda yok) | +| 40 | **Uygulama production'da güvensiz secret ile sessizce açılıyor** | M8 | Kod | A9 (çözüldü: `Config.Validate`, k3d'de zayıf JWT ile rollout takıldı) | | 41 | Otomatik secret taraması yok | M8 | Kod | A9, A5a (gitleaks; GitHub secret scanning ve push protection zaten açıktı, API ile doğrulandı) | | 42 | `kubeconform` anlamsal hataları yakalayamıyor | M5, M7 | Kod | A10 | | 43 | Docker/k3s `ufw`'yi atlıyor | M2 | Bilgi | B2 | @@ -1484,7 +1512,7 @@ manifest okunarak tespit edildi, henüz çalışan bir sistemde test edilmedi. | 53 | `ReverseProxy.Director` Go 1.26'da deprecated; `Rewrite`'a geçiş XFF ve `Host` davranışını değiştiriyor | M3 | Ölçüldü (staticcheck SA1019) | `fix/gateway-trusted-proxies` (çözüldü) | | 54 | GO-2026-6505 (otel otlptrace v1.44.0, erişilebilir); Trivy kırmızıyken govulncheck adımı hiç çalışmadığı için görünmüyordu | M7 | Ölçüldü (CI run 38416862319, yerel govulncheck) | `fix/deps-x-net` (çözüldü), maskeleme A5a (çözüldü: ayrı job'lar) | | 55 | Presence (8086) ve websocket (8084) compose'da `0.0.0.0`'a yayınlı; presence kimliksiz bir iç API (LAN'dan herhangi bir oyuncunun durumu değiştirilebilir) | M2 | Ölçüldü (token'sız `POST /presence/connect` → 200) | A2a (çözüldü) | -| 56 | k8s'teki Redis şifresiz; NetworkPolicy olmadan cluster'daki her pod erişebiliyor | M5, M8 | Kod | A9 | +| 56 | k8s'teki Redis şifresiz; NetworkPolicy olmadan cluster'daki her pod erişebiliyor | M5, M8 | Kod | A9 (çözüldü: `requirepass`, şifresiz erişim `NOAUTH`) | | 57 | Postgres DSN'inde `connect_timeout`/`statement_timeout` yok; ağ bölünmesinde sorgular ve açılış asılı kalabilir | M4 | Kod | A3a (çözüldü: 75 sn → 5 sn, açılışta retry) | | 58 | Docker Desktop kapanırken container'ları "elle durduruldu" işaretliyor; `unless-stopped` stack'i geri getirmiyor (A2a iddiasının düzeltmesi) | M2 | Ölçüldü (`HasBeenManuallyStopped: true`, 14/14) | Karar: `restart: always` (Docker Desktop yeniden açılışında doğrulanacak) | | 59 | Açık WS bağlantısı JWT'nin süresi dolduktan sonra da açık kalıyor (token sadece el sıkışmada kontrol ediliyordu) | M8 | Kod, test ile doğrulandı | A3b (çözüldü: tarama `1008 "token expired"`) | @@ -1495,7 +1523,7 @@ manifest okunarak tespit edildi, henüz çalışan bir sistemde test edilmedi. | 64 | Event bus bağlantısında açılış retry'ı yok; bağımlılık geç kalkınca servis `fatal` verip yeniden başlıyor | M4 | Ölçüldü (k3d: websocket 2 restart, `event bridge failed`) | A7 (çözüldü: 30 sn'lik bağlantı retry'ı; k3d'de 0 restart) | | 65 | Tracing kapanışı sınırsız (`shutdownTracing(context.Background())`, 7 servis); collector yokken her pod kapanışı ~18 sn uzuyor | M4 | Ölçüldü (k3d: ~24 sn → `OTEL_ENABLED=false` ile ~6 sn) | PR #44 (çözüldü: 10,01 → 3,00 sn; k3d'de 24 → 9 sn) | | 66 | Üçüncü taraf imajlar (NATS, Redis, Postgres, monitoring, tracing) hiç taranmıyordu; yerelde 7'den 6'sı düşüyor, ikisi EOL Alpine üzerinde | M7 | Ölçüldü (Trivy 0.70.0, yerel) | A7-2 (prod imajları bloklayan, `.trivyignore.yaml` süreli) | -| 67 | Grafana admin şifresi manifest'te düz metin (`admin`/`admin`) | M8 | Kod | A9 | +| 67 | Grafana admin şifresi manifest'te düz metin (`admin`/`admin`) | M8 | Kod | A9 (çözüldü: Secret'tan; `admin/admin` 401) | | 68 | Monitoring ve tracing imajları major sürüm geride (Prometheus v2.54.1, Grafana 11.2.0, Jaeger 1.62.0, collector 0.111.0); CI'da rapor modunda | M7 | Ölçüldü (#66 taraması) | Faz 1 sonrası (minor'lar 2026-10-19'da yükseltildi: Grafana 11.6.16 ve Jaeger 1.76 artık EOL Alpine üzerinde değil, 141→74 / 76→73; major'lar bekliyor); A8'de monitoring prod overlay'inin dışında | | 69 | Dependabot k8s ve compose imajlarını ayrı PR'larda güncelliyor; "ortak imajlar aynı olmalı" kontrolü zorunlu `ci-ok` altında her PR'ı tek başına kırmızı yapıyor, hiçbiri merge edilemiyor (#46'nın tasarım hatası) | M7 | Ölçüldü (#48, #50, #52, #53: `lint` fail, `grafana differs`) | `ci/dependabot-image-group` (multi-ecosystem group) | | 70 | CI job'larında `timeout-minutes` yok (varsayılan 360 dk); asılı bir job `main` kuyruğunu ve deploy'u saatlerce bekletebilir | M7 | Ölçüldü (`main`'de websocket build'i 785 sn, PR'da aynı kod 103/105 sn) | `ci/job-timeouts` | diff --git a/docs/deployment.md b/docs/deployment.md index e945ff4..14d8743 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,8 +74,13 @@ make k8s-delete # remove the local overla ### Production secrets -The local overlay generates `gamemesh-secrets` from dev values -(`overlays/local/secrets.env`). The production overlay generates no Secret: +The local overlay generates `gamemesh-secrets` from +`overlays/local/.env.local`, which is gitignored and filled with random values +by `make k8s-secrets` (run by `make k8s-apply`). Postgres sets its password only +when it initialises its volume: after changing `POSTGRES_PASSWORD`, delete the +`postgres-data` PVC (local data is lost) or `ALTER USER`; otherwise the new pods +fail with `password authentication failed` while the old ones keep serving. +The production overlay generates no Secret: the cluster must hold one, created out of band and never committed: ```bash @@ -83,9 +88,15 @@ kubectl -n gamemesh create secret generic gamemesh-secrets \ --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \ --from-literal=POSTGRES_USER=gamemesh \ --from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 32)" \ - --from-literal=POSTGRES_DB=gamemesh + --from-literal=POSTGRES_DB=gamemesh \ + --from-literal=REDIS_PASSWORD="$(openssl rand -hex 32)" ``` +With `APP_ENV=production` (the base config) every service refuses to start if +a secret it uses is missing, shorter than 32 characters or a known development +default (`config.Validate`): a forgotten or weak Secret stalls the rollout and +the running pods keep serving. + (or External Secrets Operator / Sealed Secrets / a cloud secret manager). ## 3. Grafana dashboard import (non-provisioned Grafana) diff --git a/pkg/config/config.go b/pkg/config/config.go index 37b2f70..16803d1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,9 @@ package config import ( + "errors" + "fmt" + "net/url" "os" "strconv" "strings" @@ -281,3 +284,83 @@ func splitCSV(s string) []string { } return out } + +// Need names a secret a service depends on; see Validate. +type Need int + +const ( + // NeedJWT is JWT_SECRET, the key tokens are signed and verified with. + NeedJWT Need = iota + // NeedPostgres is the password in POSTGRES_DSN. + NeedPostgres + // NeedRedis is REDIS_PASSWORD. + NeedRedis +) + +// minSecretLen is the shortest secret Validate accepts in production. The +// production values come from openssl rand -hex 32 (64 characters). +const minSecretLen = 32 + +// knownDevSecrets are the development defaults long enough to pass the length +// check: the code's own JWT default and the one in .env.example. They are +// public in this repository, so a token signed with them proves nothing. +var knownDevSecrets = map[string]bool{ + "insecure-dev-secret-do-not-use-in-prod": true, + "change-me-generate-a-real-secret": true, +} + +// Validate refuses to run in production (APP_ENV=production) with a secret +// the service needs that is unset, shorter than minSecretLen or a known +// development default. Without it a production deployment started silently +// with the JWT default from this public repository, and anyone could mint a +// valid token for any player. A service lists only what it uses, so one that +// never talks to Postgres is not failed for the DSN default. Outside +// production it accepts the defaults. The error names the setting, never its +// value. +func (c *Config) Validate(needs ...Need) error { + if c.Env != "production" { + return nil + } + var errs []error + for _, n := range needs { + switch n { + case NeedJWT: + errs = append(errs, checkSecret("JWT_SECRET", c.JWTSecret)) + case NeedPostgres: + errs = append(errs, checkSecret("the password in POSTGRES_DSN", dsnPassword(c.PostgresDSN))) + case NeedRedis: + errs = append(errs, checkSecret("REDIS_PASSWORD", c.RedisPassword)) + default: + errs = append(errs, fmt.Errorf("unknown config.Need %d", n)) + } + } + return errors.Join(errs...) +} + +func checkSecret(name, value string) error { + switch { + case value == "": + return fmt.Errorf("%s is not set", name) + case knownDevSecrets[value]: + return fmt.Errorf("%s is a known development default", name) + case len(value) < minSecretLen: + return fmt.Errorf("%s is shorter than %d characters", name, minSecretLen) + } + return nil +} + +// dsnPassword returns the password of a Postgres DSN in either form pgx +// accepts: key=value ("... password=x ...", value optionally quoted) or URL +// ("postgres://user:x@host/db"). +func dsnPassword(dsn string) string { + if u, err := url.Parse(dsn); err == nil && (u.Scheme == "postgres" || u.Scheme == "postgresql") { + p, _ := u.User.Password() + return p + } + for _, field := range strings.Fields(dsn) { + if v, ok := strings.CutPrefix(field, "password="); ok { + return strings.Trim(v, "'") + } + } + return "" +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 294630d..ad7f9f0 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,10 +1,12 @@ package config import ( + "strings" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLoadDefaults(t *testing.T) { @@ -97,3 +99,76 @@ func TestServiceVersionFromBuild(t *testing.T) { t.Setenv("SERVICE_VERSION", "override") assert.Equal(t, "override", Load("gateway").ServiceVersion) } + +// strongSecret is long enough and not a known default. Built at run time: as +// a literal assigned to a name with "secret" in it, it matched gitleaks' +// generic-api-key rule (see .gitleaksignore for the commit that had it). +var strongSecret = strings.Repeat("0123456789abcdef", 4) + +func TestValidateAcceptsDefaultsOutsideProduction(t *testing.T) { + cfg := Load("player") + assert.NoError(t, cfg.Validate(NeedJWT, NeedPostgres, NeedRedis)) +} + +// TestValidateRefusesWeakSecretsInProduction: each weak shape is refused with +// the setting's name, and the message never contains the value. An empty +// JWT_SECRET falls back to the code's default, so "not set" shows on +// REDIS_PASSWORD, whose default is empty. +func TestValidateRefusesWeakSecretsInProduction(t *testing.T) { + t.Setenv("APP_ENV", "production") + for name, tc := range map[string]struct { + env map[string]string + need Need + want string + }{ + "JWT default": {map[string]string{}, NeedJWT, "JWT_SECRET is a known development default"}, + "JWT empty": {map[string]string{"JWT_SECRET": ""}, NeedJWT, "JWT_SECRET is a known development default"}, + "JWT too short": {map[string]string{"JWT_SECRET": "short-secret"}, NeedJWT, "JWT_SECRET is shorter than 32 characters"}, + "Redis not set": {map[string]string{}, NeedRedis, "REDIS_PASSWORD is not set"}, + "Postgres short": {map[string]string{"POSTGRES_DSN": "host=db password=short-password"}, NeedPostgres, "the password in POSTGRES_DSN is shorter than 32 characters"}, + } { + t.Run(name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + cfg := Load("gateway") + err := cfg.Validate(tc.need) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + for _, secret := range []string{cfg.JWTSecret, cfg.RedisPassword, dsnPassword(cfg.PostgresDSN)} { + if secret != "" { + assert.NotContains(t, err.Error(), secret) + } + } + }) + } +} + +func TestValidateChecksOnlyWhatTheServiceNeeds(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("REDIS_PASSWORD", strongSecret) + // leaderboard never talks to Postgres or signs tokens: the DSN and JWT + // defaults do not concern it. + assert.NoError(t, Load("leaderboard").Validate(NeedRedis)) + + err := Load("player").Validate(NeedJWT, NeedPostgres, NeedRedis) + require.Error(t, err) + assert.Contains(t, err.Error(), "JWT_SECRET") + assert.Contains(t, err.Error(), "the password in POSTGRES_DSN") + assert.NotContains(t, err.Error(), "REDIS_PASSWORD") +} + +func TestValidateAcceptsStrongSecretsInProduction(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("JWT_SECRET", strongSecret) + t.Setenv("REDIS_PASSWORD", strongSecret) + t.Setenv("POSTGRES_DSN", "host=postgres user=gamemesh password="+strongSecret+" dbname=gamemesh sslmode=disable connect_timeout=5") + assert.NoError(t, Load("player").Validate(NeedJWT, NeedPostgres, NeedRedis)) +} + +func TestDSNPassword(t *testing.T) { + assert.Equal(t, "s3cret", dsnPassword("host=db user=u password=s3cret dbname=x")) + assert.Equal(t, "s3cret", dsnPassword("host=db password='s3cret' dbname=x")) + assert.Equal(t, "s3cret", dsnPassword("postgres://u:s3cret@db:5432/x?sslmode=disable")) + assert.Equal(t, "", dsnPassword("host=db user=u dbname=x")) +} diff --git a/scripts/k8s/local-secrets.sh b/scripts/k8s/local-secrets.sh new file mode 100755 index 0000000..db56031 --- /dev/null +++ b/scripts/k8s/local-secrets.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Writes the local k3d overlay's secrets to deployments/k8s/overlays/local/.env.local +# (gitignored), generating each missing value with openssl rand -hex 32. Keys +# already in the file are kept, so a value only changes when the line is +# deleted. Never prints a value. +# +# Changing POSTGRES_PASSWORD on a cluster that already has a Postgres volume +# does not change the database's password (it is set once, at initdb): delete +# the postgres-data PVC or ALTER USER as well. +set -eu +file=deployments/k8s/overlays/local/.env.local +umask 077 +touch "$file" +add() { # key value + grep -q "^$1=" "$file" || { printf '%s=%s\n' "$1" "$2" >> "$file"; echo "generated $1"; } +} +add POSTGRES_USER gamemesh +add POSTGRES_DB gamemesh +for key in JWT_SECRET POSTGRES_PASSWORD REDIS_PASSWORD GRAFANA_ADMIN_PASSWORD; do + add "$key" "$(openssl rand -hex 32)" +done