From 3d8457984bbaf70865e8c933ef06c07f95d499cb Mon Sep 17 00:00:00 2001 From: AlpNuhoglu Date: Mon, 19 Oct 2026 23:05:59 +0300 Subject: [PATCH 1/4] build(k8s): keep the client's address through k3s's Traefik The gateway trusts Traefik (TRUSTED_PROXIES 10.42.0.0/16), but on k3d it logged every client as 10.42.0.1: ServiceLB SNATs connections before they reach Traefik under the default externalTrafficPolicy (Cluster), so Traefik never sees the client and all users share one rate-limit bucket (finding #52). deployments/k8s/cluster/traefik-config.yaml is a k3s HelmChartConfig for the Traefik chart k3s ships, setting externalTrafficPolicy: Local. It configures the cluster, not the application, so it sits outside the overlays and is applied once to a new cluster (B). Measured on a second k3d cluster with the production overlay: the Traefik Service reports externalTrafficPolicy=Local and the gateway logs the connection's real source, 172.21.0.4 (k3d's proxy container in front of the node), where the local cluster still logs 10.42.0.1. On the VPS that source is the client itself; the final check is B4. Plan: A10 Refs: Bulgu #52 Co-Authored-By: Claude Opus 5.5 --- deployments/k8s/cluster/traefik-config.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 deployments/k8s/cluster/traefik-config.yaml diff --git a/deployments/k8s/cluster/traefik-config.yaml b/deployments/k8s/cluster/traefik-config.yaml new file mode 100644 index 0000000..c6f09d0 --- /dev/null +++ b/deployments/k8s/cluster/traefik-config.yaml @@ -0,0 +1,20 @@ +# k3s settings for the cluster itself, not the application: applied once to +# a new cluster (B), kept out of the overlays. kubectl apply -f this file. +# +# HelmChartConfig is k3s's way to override the values of a chart it ships +# (here Traefik, in kube-system). k3s re-renders the chart when it changes. +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + service: + spec: + # With the default (Cluster) ServiceLB SNATs every connection before + # it reaches Traefik: the gateway saw each client as 10.42.0.1, so + # all users shared one rate-limit bucket (finding #52). Local keeps + # the client's source address; on a single node it costs nothing, + # since every Traefik pod is local. + externalTrafficPolicy: Local From 400eb4fdeb0aad50d9dc5de50af8dfb3f5925ec1 Mon Sep 17 00:00:00 2001 From: AlpNuhoglu Date: Tue, 20 Oct 2026 00:06:45 +0300 Subject: [PATCH 2/4] docs: describe the one-time settings of a new k3s cluster Two settings belong to the cluster, not to the overlays, and are easy to miss on a fresh VPS: k3s's encryption at rest for Secrets, which is off by default (k3d's default cluster reports "Encryption Status: Disabled"; a cluster created with --secrets-encryption reports Enabled), and the Traefik HelmChartConfig that keeps the client's address. Both were checked on the rehearsal cluster. Plan: A10 Co-Authored-By: Claude Opus 5.5 --- docs/deployment.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/deployment.md b/docs/deployment.md index 14d8743..cf1bd67 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -72,6 +72,20 @@ kubectl -n gamemesh exec redis-0 -- redis-cli zcard matchmaking:queue make k8s-delete # remove the local overlay ``` +### Cluster settings (once per new cluster) + +Settings for the k3s cluster itself, outside the overlays: + +```bash +# k3s installed with encryption at rest for Secrets (off by default): +# curl -sfL https://get.k3s.io | sh -s - --secrets-encryption +k3s secrets-encrypt status # Encryption Status: Enabled + +# Traefik keeps the client's address (externalTrafficPolicy: Local); without +# it every client reaches the gateway as 10.42.0.1 and shares one rate limit. +kubectl apply -f deployments/k8s/cluster/traefik-config.yaml +``` + ### Production secrets The local overlay generates `gamemesh-secrets` from From fed987e8d7e075f193584981f8c7ea459e7080e2 Mon Sep 17 00:00:00 2001 From: AlpNuhoglu Date: Tue, 20 Oct 2026 00:09:49 +0300 Subject: [PATCH 3/4] docs(plan): record the A10 rehearsal and its checklist Records the production overlay rehearsal on a second k3d cluster (Secret created by hand, the commit's GHCR images, secrets encryption on): 10/10 pods first time and /health 200 with the commit SHA through Traefik. Then the A10 checklist, measured through Traefik: no failed request during gateway and leaderboard rollouts (1050/1050 and 1032/1032), 1001 on a websocket rollout, Redis down handled by readiness without restarts, a config change rolling every Deployment and undone, a drain not blocked by the PDB, a logged-out token refused on /ws, an idle WebSocket kept open by the server's pings, and the client address kept with externalTrafficPolicy Local (final check B4). New: #74 Secrets not encrypted at rest in k3s by default, #75 stale generated Secrets in the local cluster. Notes for D (rollback by redeploying, not rollout undo) and a post-phase item (503 for a missing upstream). Plan: A10 Refs: Bulgu #29, Bulgu #30, Bulgu #31, Bulgu #35, Bulgu #52, Bulgu #74, Bulgu #75 Co-Authored-By: Claude Opus 5.5 --- docs/deployment-phase1-plan.md | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/deployment-phase1-plan.md b/docs/deployment-phase1-plan.md index d71e713..d40ea16 100644 --- a/docs/deployment-phase1-plan.md +++ b/docs/deployment-phase1-plan.md @@ -1115,6 +1115,42 @@ Her adım şu yapıda: Traefik herkesi aynı iç IP olarak görür. Çözüm adayı, `HelmChartConfig` ile `externalTrafficPolicy: Local`. k3d'de node'un önünde k3d'nin kendi proxy'si de olduğu için kesin sonuç B4'te, VPS üzerinde gerçek bir dış istemciyle alınmalı. +- **Kararlar (2026-10-19):** prod overlay'i ayrı bir k3d cluster'ında (`gamemesh-prod`, API + `127.0.0.1:6551`, Traefik `127.0.0.1:18081`, `--secrets-encryption` ile) provası; Traefik + `HelmChartConfig` (`externalTrafficPolicy: Local`) `deployments/k8s/cluster/` altında; + kalan ölçümler bu cluster'da Traefik üzerinden. +- **Prod provası (2026-10-19/20):** Secret elle (`kubectl create secret`, `openssl rand -hex + 32`, değerler hiç basılmadan), overlay'in kopyasında `kustomize edit set image` ile + `main`'in SHA'sı (`4348c89`). İlk denemede 10/10 `1/1`, 0 restart; 7 uygulama imajı GHCR'dan, + üçüncü taraf digest'ler sabitlenenlerle aynı; fail-fast'a takılan servis yok; Traefik + + `Host: api.gamemesh.invalid` ile `/health` 200 ve `version` = commit SHA'sı. Not: k3d + cluster oluştururken aktif kubectl context'ini sessizce yeni cluster'a geçirdi; geri + alındı, prova komutları açık `--context` ile verildi. + + | # | Kontrol | Sonuç | + |---|---|---| + | 1 | WS fan-out | ✅ A7-3 (iki pod, port-forward ile 3/3). Prod'da websocket 1 replica | + | 2 | Rollout'ta tek matchmaking | ✅ A7-1 (36/88 → 0) | + | 3 | Rolling update'te hata yok; WS 1001 | ✅ gateway rollout'u (1 replica), `/health` 50 ms'de bir: 1050/1050 200; leaderboard rollout'u, proxy'lenen rota: 1032/1032 200; websocket rollout'unda Traefik üzerinden bağlı istemci `1001 "server shutting down"` | + | 4 | Redis kapalıyken | ✅ gateway `1/1`, `/health` 200; Redis'e bağlı 5 servis `0/1`, 0 restart; outbox-relay `1/1`; Redis'e bağlı rotalar 502; Redis dönünce 10/10 | + | 5 | ConfigMap değişikliği | ✅ `RATE_LIMIT_RPS` 50 → 60: yeni hash'li ConfigMap, 7 Deployment kendiliğinden yeniden başladı; `rollout undo` gateway'i eski ConfigMap'e (50) döndürdü | + | 6 | Hatalı deploy | ✅ A9 (zayıf JWT, yanlış Postgres şifresi): yeni pod takılıyor, eski pod'lar trafik alıyor | + | 7 | Drain | ✅ 37 sn'de tamamlandı, PDB kilitlemedi; tek node'da pod'lar `Pending`, uncordon sonrası 10/10 | + | 8 | Logout sonrası WS | ✅ Traefik üzerinden: önce 101, logout 200, sonra 401 | + | 9 | Gerçek istemci IP'si | Kısmen: `Cluster` politikasıyla herkes `10.42.0.1` (SNAT); `Local` ile bağlantının gerçek kaynağı (`172.21.0.4`, k3d'nin önündeki proxy). Kesin sonuç B4 | + | + | WS boşta kalma (Traefik) | ✅ 130 sn boşta açık kaldı, sunucudan 2 ping (54 sn) | + + - `rollout undo` uyarısı: "Rolling back will not update the last-applied-configuration + annotation": bir sonraki `apply` geri alınanı yeniden uygular. CI'lı bir sistemde asıl + geri alma, önceki commit'i yeniden deploy etmek (D). + - Gateway, hazır endpoint'i olmayan bir arka servis için 502 dönüyor; geçici bir durum + için 503 + `Retry-After` daha açık olurdu (Faz 1 sonrası). + - Gözlem: A10 sırasında `commit.sh`'teki `go test -race` bir kez tek bir testte düştü + (iki k3d cluster'ı ve compose aynı anda çalışırken); adı kaydedilmemişti, ardından üç tam + tekrarda yeniden üretilemedi. `checks.sh` artık test çıktısının tamamını saklıyor. +- **Bitti sayılır (2026-10-20):** `/health` Traefik üzerinden 200 (local ve prod provası), + WebSocket Traefik üzerinden bağlanıyor (101), Redis pod'u silinip yeniden oluşturulunca + veri duruyor (A7-2). Yukarıdaki 9 kontrolün 8'i tamam, 9. madde B4'te. --- @@ -1442,6 +1478,8 @@ Bunlar unutulmadı; Faz 1'i sade tutmak için ertelendi: - **Monitoring ve tracing imajlarının major yükseltmesi (#68):** Prometheus 3, Grafana 12/13, Jaeger v2 (config biçimi değişiyor), collector 0.16x. Yapıldıktan sonra #66 taramasındaki `REPORT_ONLY` listesinden çıkarılmalı. +- **Gateway'de upstream yokken 503 + `Retry-After` (A10):** Bugün 502; geçici bir durum için + 503 istemciye yeniden denemeyi açıkça söyler. - **Kalan iki taşınabilir referans (A5a):** Dockerfile'daki `# syntax=docker/dockerfile:1` satırı bir tag (digest ile sabitlenebilir; Dependabot'un bu satırı güncelleyip güncellemediği test edilmedi). Trivy sürümü action'ın varsayılanına bağlı; `version:` @@ -1530,3 +1568,5 @@ manifest okunarak tespit edildi, henüz çalışan bir sistemde test edilmedi. | 71 | Dependabot sonek taşıyan tek mimarili bir tag'i (`otel-collector:0.162.0-386`, sadece `linux/386`) yeni sürüm sandı; CI yeşil, çünkü collector sadece taranıyor | M7 | Ölçüldü (`imagetools inspect`: tek manifest, `linux 386`) | PR #58 (collector Dependabot'ta ignore; #68 ile elle) | | 72 | Ingress `/health`'i yönlendirmiyordu (sadece `/api`, `/ws`); Faz 1'in hedef komutu sınıf düzeltilse de 404 olurdu | M5 | Ölçüldü (k3d: Traefik 404) | A8 (çözüldü: `/health` Exact) | | 73 | k8s DSN'lerinde `connect_timeout` yok (A3a düzeltmesi k8s'e ulaşmamış); tek deneme 39 sn asılı kalıp player'ı restart ettirdi | M4 | Ölçüldü (k3d) | A8 (çözüldü: `connect_timeout=5`, 0 restart) | +| 74 | k3s'te Secret'lar diskte şifrelenmiyor (`Encryption Status: Disabled`); datastore dosyası ya da yedeği bütün Secret'ları base64 olarak içeriyor | M8 | Ölçüldü (k3d: `k3s secrets-encrypt status`) | B (k3s `--secrets-encryption` ile kurulacak; prova cluster'ında `Enabled` doğrulandı) | +| 75 | Local overlay'de her secret değişikliği yeni hash'li bir Secret üretiyor, eskileri eski değerlerle cluster'da kalıyor | M8 | Ölçüldü (k3d: 3 `gamemesh-secrets-*`) | Sadece local (prod'da generator yok); `kubectl apply --prune` ya da elle temizlik | From 7b9122e568c0248f5c41c63af9216799bb8cb459 Mon Sep 17 00:00:00 2001 From: AlpNuhoglu Date: Tue, 20 Oct 2026 00:11:07 +0300 Subject: [PATCH 4/4] docs(plan): mark A9 done and close finding #58 A9: records #63's CI (a gitleaks false positive on a test fixture, cleared without rewriting history, then 24 checks green) and the measurements on how Secrets are stored (base64 only, no encryption at rest in k3d's default k3s, unreadable to the services' account). #58: after Docker Desktop was reopened, all 14 compose containers came back on their own with restart: always, the behaviour A2a expected and unless-stopped did not give. Plan: A9 Refs: Bulgu #58, Bulgu #74 Co-Authored-By: Claude Opus 5.5 --- docs/deployment-phase1-plan.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/deployment-phase1-plan.md b/docs/deployment-phase1-plan.md index d40ea16..b313964 100644 --- a/docs/deployment-phase1-plan.md +++ b/docs/deployment-phase1-plan.md @@ -1006,7 +1006,7 @@ Her adım şu yapıda: ### A9. Secret'ları repodan çıkar -- [ ] **Ne:** `01-secrets.yaml`'ı base'den kaldırmak. +- [x] **Ne:** `01-secrets.yaml`'ı base'den kaldırmak. - **local overlay:** dev değerleri `secretGenerator` ile, gitignore'daki bir `.env.local`'dan okunuyor. - **prod:** Secret repoda **hiç yer almıyor**. CI, GitHub Environment secret'larından @@ -1067,6 +1067,15 @@ Her adım şu yapıda: - 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. +- **Sonuç (2026-10-19), PR #63 → `main` `4348c89`:** İlk CI'da gitleaks kırmızıydı ve bulgunun + yerini göstermiyordu (`--verbose` yoktu). Yerelde `--verbose --redact`: `generic-api-key`, + `pkg/config/config_test.go:103`, test sabiti `strongSecret`; gerçek bir secret değil. Geçmiş + yeniden yazılamadığı için test değeri çalışma anında üretiliyor ve o tek bulgu + `.gitleaksignore`'da parmak iziyle (commit SHA'sına bağlı; branch merge commit'le + güncellendi, SHA `main`'de korundu) kabul edildi; CI'daki gitleaks artık `--verbose`. Sonra + 24 check yeşil, gitleaks 161 commit "no leaks found". Ölçümler: Secret API'de base64 (88 + karakter), `base64 -d` değeri anahtarsız geri veriyor; k3s'te diskte şifreleme kapalı (#74); + uygulama ServiceAccount'u Secret okuyamıyor (`can-i` → no). ### A10. Prova: yerel k3s (k3d) üzerinde prod'a en yakın deploy @@ -1552,7 +1561,7 @@ manifest okunarak tespit edildi, henüz çalışan bir sistemde test edilmedi. | 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 (çö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) | +| 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` (çözüldü, 2026-10-19: Docker Desktop yeniden açılınca 14/14 container kendiliğinden ayağa kalktı) | | 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"`) | | 60 | Trivy'ye `scanners` verilmemiş; step adı "misconfig" dese de manifest'ler hiç taranmıyordu (33 HIGH görünmüyordu) | M7 | Ölçüldü (yerel Trivy 0.70.0) | A5a (misconfig sadece rapor; bloklama A7) | | 61 | `main` ruleset'inde zorunlu check'ler sadece `lint`, `test`, `integration`; taramalar, gitleaks ve imaj build/taramaları kırmızıyken de merge mümkün (A5a'daki "bloklayan" iddiasının düzeltmesi) | M7 | Ölçüldü (rulesets API) | A5a takibi: `ci-ok` toplayıcı job'u; ruleset değişikliği kullanıcı tarafından |