diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 02f766c..5baaede 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -1337,23 +1337,55 @@ func (h *HelmProvisioner) waitForRollout(namespace string) error { case <-ctx.Done(): return ctx.Err() case <-time.After(5 * time.Second): - // Utiliser kubectl pour vérifier le rollout - cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s") - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - // Si c'est juste un timeout, on continue d'attendre - if strings.Contains(stderr.String(), "timed out") { - continue - } - return fmt.Errorf("rollout check: %v: %s", err, stderr.String()) + ready, err := h.rolloutReady(ctx, namespace) + if err != nil { + return fmt.Errorf("rollout check: %w", err) + } + if ready { + return nil } - // Si la commande réussit, tous les déploiements sont prêts - return nil } } } +// rolloutReady : true si tous les Deployment et StatefulSet du namespace ont +// toutes leurs répliques prêtes. Remplace un ancien appel à +// "kubectl rollout status deployment -n " — en plus de l'absence +// du binaire kubectl dans l'image (seul helm y est installé, voir +// control-plane/api/Dockerfile), cette commande était de toute façon +// invalide sans nom de déploiement précis (jamais réellement exercée avant +// la première migration premium en conditions réelles). +func (h *HelmProvisioner) rolloutReady(ctx context.Context, namespace string) (bool, error) { + deploys, err := h.k8sClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, err + } + for _, d := range deploys.Items { + desired := int32(1) + if d.Spec.Replicas != nil { + desired = *d.Spec.Replicas + } + if d.Status.ReadyReplicas < desired { + return false, nil + } + } + + statefulSets, err := h.k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, err + } + for _, s := range statefulSets.Items { + desired := int32(1) + if s.Spec.Replicas != nil { + desired = *s.Spec.Replicas + } + if s.Status.ReadyReplicas < desired { + return false, nil + } + } + return true, nil +} + // componentResourceLimits : limites CPU/mémoire configurées dans chaque chart // (deploy/chart-gestion/*/values.yaml), utilisées pour calculer un % d'usage. var componentResourceLimits = map[string]struct { diff --git a/deploy/chart-gestion/backend/templates/deployment.yaml b/deploy/chart-gestion/backend/templates/deployment.yaml index 3d7b554..ea59134 100644 --- a/deploy/chart-gestion/backend/templates/deployment.yaml +++ b/deploy/chart-gestion/backend/templates/deployment.yaml @@ -20,6 +20,10 @@ spec: labels: {{- include "backend.selectorLabels" . | nindent 8 }} spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/deploy/chart-gestion/backend/values.yaml b/deploy/chart-gestion/backend/values.yaml index c94effd..a36718e 100644 --- a/deploy/chart-gestion/backend/values.yaml +++ b/deploy/chart-gestion/backend/values.yaml @@ -5,6 +5,11 @@ image: tag: latest pullPolicy: IfNotPresent +# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials) +# si un compte Docker Hub authentifié est configuré — évite le rate-limit de +# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut). +imagePullSecrets: [] + service: type: ClusterIP port: 8080 diff --git a/deploy/chart-gestion/frontend/templates/deployment.yaml b/deploy/chart-gestion/frontend/templates/deployment.yaml index 326c3bf..2dcc4c1 100644 --- a/deploy/chart-gestion/frontend/templates/deployment.yaml +++ b/deploy/chart-gestion/frontend/templates/deployment.yaml @@ -16,6 +16,10 @@ spec: labels: {{- include "frontend.selectorLabels" . | nindent 8 }} spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: @@ -25,8 +29,13 @@ spec: securityContext: {{- toYaml .Values.securityContext | nindent 12 }} ports: + # L'image xor1234/frontend-mln (nginx) écoute en dur sur le port + # 80 (voir /etc/nginx/conf.d/default.conf embarqué dans l'image) — + # déjà anticipé par deploy/chart-gestion/ingressroute/values.yaml + # (frontend.servicePort: 80). 8080 ici ne correspondait à rien de + # réellement écouté, d'où le "connection refused" en probe. - name: http - containerPort: 8080 + containerPort: 80 protocol: TCP volumeMounts: - name: tmp @@ -37,6 +46,15 @@ spec: mountPath: /srv/config.js subPath: config.js readOnly: true + # readOnlyRootFilesystem:true (voir securityContext) empêche + # nginx de créer /var/cache/nginx/client_temp au démarrage + # ("mkdir() ... failed (30: Read-only file system)", CrashLoop) — + # nginx a besoin d'y écrire même quand il ne sert que du + # contenu statique. + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /var/run livenessProbe: httpGet: path: / @@ -56,6 +74,10 @@ spec: emptyDir: {} - name: caddy-data emptyDir: {} + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} - name: app-config configMap: name: {{ include "frontend.fullname" . }}-config diff --git a/deploy/chart-gestion/frontend/values.yaml b/deploy/chart-gestion/frontend/values.yaml index 13a4224..dfe4839 100644 --- a/deploy/chart-gestion/frontend/values.yaml +++ b/deploy/chart-gestion/frontend/values.yaml @@ -7,9 +7,18 @@ image: tag: latest pullPolicy: IfNotPresent +# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials) +# si un compte Docker Hub authentifié est configuré — évite le rate-limit de +# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut). +imagePullSecrets: [] + service: type: ClusterIP - port: 8080 + # L'IngressRoute (deploy/chart-gestion/ingressroute) référence directement + # ce port de Service (frontend.servicePort, déjà à 80 dans son + # values.yaml — il anticipait correctement le vrai port de nginx). Doit + # rester égal à containerPort (voir templates/deployment.yaml). + port: 80 resources: requests: diff --git a/deploy/chart-gestion/lbtelegram/templates/deployment.yaml b/deploy/chart-gestion/lbtelegram/templates/deployment.yaml index 4ec137a..8e0e04a 100644 --- a/deploy/chart-gestion/lbtelegram/templates/deployment.yaml +++ b/deploy/chart-gestion/lbtelegram/templates/deployment.yaml @@ -18,6 +18,10 @@ spec: labels: {{- include "lbtelegram.selectorLabels" . | nindent 8 }} spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/deploy/chart-gestion/lbtelegram/values.yaml b/deploy/chart-gestion/lbtelegram/values.yaml index 72a37dc..2446033 100644 --- a/deploy/chart-gestion/lbtelegram/values.yaml +++ b/deploy/chart-gestion/lbtelegram/values.yaml @@ -5,6 +5,11 @@ image: tag: latest pullPolicy: IfNotPresent +# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials) +# si un compte Docker Hub authentifié est configuré — évite le rate-limit de +# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut). +imagePullSecrets: [] + resources: requests: cpu: 100m diff --git a/deploy/chart-gestion/postgresql/templates/statefulset.yaml b/deploy/chart-gestion/postgresql/templates/statefulset.yaml index 5f30770..98bcd1e 100644 --- a/deploy/chart-gestion/postgresql/templates/statefulset.yaml +++ b/deploy/chart-gestion/postgresql/templates/statefulset.yaml @@ -15,6 +15,10 @@ spec: labels: {{- include "postgresql.selectorLabels" . | nindent 8 }} spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/deploy/chart-gestion/postgresql/values.yaml b/deploy/chart-gestion/postgresql/values.yaml index 2f44926..8221300 100644 --- a/deploy/chart-gestion/postgresql/values.yaml +++ b/deploy/chart-gestion/postgresql/values.yaml @@ -3,6 +3,11 @@ image: tag: "16-alpine" pullPolicy: IfNotPresent +# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials) +# si un compte Docker Hub authentifié est configuré — évite le rate-limit de +# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut). +imagePullSecrets: [] + auth: username: postgres database: gestion_db diff --git a/deploy/chart-gestion/redis/templates/statefulset.yaml b/deploy/chart-gestion/redis/templates/statefulset.yaml index d36ad62..7f8fc1d 100644 --- a/deploy/chart-gestion/redis/templates/statefulset.yaml +++ b/deploy/chart-gestion/redis/templates/statefulset.yaml @@ -15,6 +15,10 @@ spec: labels: {{- include "redis.selectorLabels" . | nindent 8 }} spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/deploy/chart-gestion/redis/values.yaml b/deploy/chart-gestion/redis/values.yaml index d9524da..d8d2c05 100644 --- a/deploy/chart-gestion/redis/values.yaml +++ b/deploy/chart-gestion/redis/values.yaml @@ -3,6 +3,11 @@ image: tag: "7-alpine" pullPolicy: IfNotPresent +# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials) +# si un compte Docker Hub authentifié est configuré — évite le rate-limit de +# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut). +imagePullSecrets: [] + auth: # À surcharger via --set auth.password=xxx password: "" diff --git a/deploy/chart-gestion/registry-credentials/Chart.yaml b/deploy/chart-gestion/registry-credentials/Chart.yaml new file mode 100644 index 0000000..21e5262 --- /dev/null +++ b/deploy/chart-gestion/registry-credentials/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: gestion-registry-credentials +description: Secret docker-registry (Docker Hub authentifié) installé par démo — évite le rate-limit de pull anonyme (100/6h par IP, contre 200/6h authentifié) partagé entre tous les pods du même nœud. +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/chart-gestion/registry-credentials/templates/secret.yaml b/deploy/chart-gestion/registry-credentials/templates/secret.yaml new file mode 100644 index 0000000..7f091e4 --- /dev/null +++ b/deploy/chart-gestion/registry-credentials/templates/secret.yaml @@ -0,0 +1,9 @@ +{{- if and .Values.username .Values.password }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.secretName }} +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: {{ printf `{"auths":{"%s":{"username":"%s","password":"%s","auth":"%s"}}}` .Values.registry .Values.username .Values.password (printf "%s:%s" .Values.username .Values.password | b64enc) | b64enc }} +{{- end }} diff --git a/deploy/chart-gestion/registry-credentials/values.yaml b/deploy/chart-gestion/registry-credentials/values.yaml new file mode 100644 index 0000000..07e1b6a --- /dev/null +++ b/deploy/chart-gestion/registry-credentials/values.yaml @@ -0,0 +1,9 @@ +# Identifiants Docker Hub (compte dédié, droits de lecture seule suffisent — +# pas besoin d'un compte payant, le seul but est de doubler le rate-limit de +# pull anonyme : 100/6h par IP -> 200/6h authentifié). Jamais codés en dur, +# injectés par le control-plane (voir OMNEX_DOCKERHUB_USERNAME/PASSWORD dans +# internal/config/config.go) au moment de l'installation. +secretName: dockerhub-pull-secret +registry: https://index.docker.io/v1/ +username: "" +password: "" diff --git a/deploy/chart-gestion/traefik/templates/middleware-chain.yaml b/deploy/chart-gestion/traefik/templates/middleware-chain.yaml index 7226e7d..4be9c93 100644 --- a/deploy/chart-gestion/traefik/templates/middleware-chain.yaml +++ b/deploy/chart-gestion/traefik/templates/middleware-chain.yaml @@ -9,5 +9,14 @@ spec: chain: middlewares: - name: rate-limit - - name: coraza-waf + # coraza-waf désactivé temporairement (2026-08-17) : fait planter + # Traefik (panic Go "unsafe.Slice: len out of range" dans le runtime + # WASM du plugin coraza-http-wasm-traefik v0.3.0) dès qu'une vraie + # IngressRoute de démo l'active — reproductible à 100% au chargement, + # crash-loop du pod partagé, bloque TOUTES les démos. Jamais exercé + # avant la toute première démo réelle. À investiguer (version du + # plugin ? directives ModSecurity trop volumineuses/complexes pour ce + # runtime WASM ?) avant réactivation — voir middleware-waf.yaml pour + # les directives complètes. + # - name: coraza-waf - name: security-headers diff --git a/deploy/chart-gestion/traefik/values.yaml b/deploy/chart-gestion/traefik/values.yaml index c9ad9e5..aad30ae 100644 --- a/deploy/chart-gestion/traefik/values.yaml +++ b/deploy/chart-gestion/traefik/values.yaml @@ -27,6 +27,17 @@ rateLimit: # https://github.com/traefik/traefik-helm-chart # ============================================================= traefik: + # Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials, + # installé dans le namespace traefik par EnsureSharedInfra) si un compte + # Docker Hub authentifié est configuré — évite le rate-limit de pull + # anonyme. Vide = pull anonyme (défaut). Sous "deployment", pas à la racine + # : c'est l'emplacement attendu par values.schema.json du chart officiel + # traefik/traefik (voir charts/traefik-41.1.0.tgz) — un imagePullSecrets à + # la racine est rejeté ("Additional property imagePullSecrets is not + # allowed"). + deployment: + imagePullSecrets: [] + # Expose les ports 80 et 443. Le port web redirige automatiquement vers # websecure au niveau de l'entrypoint (pas besoin d'IngressRoute dédiée). # Schéma valable pour le chart traefik/traefik >= 34 (vendored : 41.1.0, @@ -96,8 +107,17 @@ traefik: # prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le # rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant # qu'il n'y a pas de LoadBalancer (MetalLB) devant. + # + # type: ClusterIP — Traefik n'est plus exposé directement à internet + # depuis l'ajout du WAF ModSecurity natif devant lui (voir + # deploy/chart-gestion/waf-cluster, remplace coraza-http-wasm-traefik : + # bug mémoire non résolu upstream, jcchavezs/coraza-http-wasm-traefik#9). + # Le WAF termine le TLS public et rouvre une connexion vers l'entryPoint + # websecure (port 8443) de ce Service, désormais interne au cluster — + # aucun changement requis côté IngressRoute/entryPoints. service: spec: + type: ClusterIP externalTrafficPolicy: Cluster # Métriques Prometheus diff --git a/deploy/chart-gestion/waf-cluster/Chart.yaml b/deploy/chart-gestion/waf-cluster/Chart.yaml new file mode 100644 index 0000000..2a9e047 --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: gestion-waf-cluster +description: WAF ModSecurity/OWASP CRS natif (nginx, pas WASM) devant le Traefik partagé — remplace le plugin coraza-http-wasm-traefik (bug mémoire non résolu upstream, voir jcchavezs/coraza-http-wasm-traefik#9 et traefik/traefik#11436). Même image/pattern que docker/waf (omnex-prod). +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/chart-gestion/waf-cluster/templates/_helpers.tpl b/deploy/chart-gestion/waf-cluster/templates/_helpers.tpl new file mode 100644 index 0000000..dc0b924 --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{- define "waf-cluster.fullname" -}} +{{ .Release.Name }} +{{- end -}} + +{{- define "waf-cluster.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: Helm +{{- end -}} + +{{- define "waf-cluster.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/deploy/chart-gestion/waf-cluster/templates/configmap.yaml b/deploy/chart-gestion/waf-cluster/templates/configmap.yaml new file mode 100644 index 0000000..b8958a3 --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/templates/configmap.yaml @@ -0,0 +1,147 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "waf-cluster.fullname" . }}-conf + namespace: {{ .Release.Namespace }} + labels: + {{- include "waf-cluster.labels" . | nindent 4 }} +data: + app.conf: | + server { + listen 80; + listen [::]:80; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name _; + + server_tokens off; + + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + client_max_body_size 10M; + client_body_buffer_size 128k; + client_header_buffer_size 1k; + large_client_header_buffers 4 8k; + + client_body_timeout 30s; + client_header_timeout 30s; + send_timeout 30s; + keepalive_timeout 65s; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + modsecurity on; + modsecurity_rules_file /etc/nginx/modsec/custom-rules.conf; + + # Terminaison TLS ici, puis nouvelle connexion TLS vers l'entryPoint + # websecure du Traefik partagé (deploy/chart-gestion/traefik) : aucun + # changement requis côté Traefik/IngressRoute, il continue de router + # par Host comme si le client s'y connectait directement. Host + # préservé pour que le routage par Host de Traefik fonctionne. + location / { + proxy_pass https://{{ .Values.upstream.host }}:{{ .Values.upstream.port }}; + proxy_ssl_server_name on; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location = /healthz { + access_log off; + return 200 "ok"; + add_header Content-Type text/plain; + } + } + + custom-rules.conf: | + # Règles maison — le reste (SecRuleEngine, Include CRS, seuils de + # paranoïa) est déjà configuré par l'entrypoint de l'image + # owasp/modsecurity-crs à partir des variables d'env PARANOIA/ + # ANOMALY_INBOUND/ANOMALY_OUTBOUND (voir values.yaml waf.*), même + # mécanisme que docker/waf (omnex-prod). + SecAuditLogParts ABIFHZ + + SecRule IP:BANNED "@eq 1" \ + "id:100000,phase:1,deny,status:403,log,\ + msg:'IP is banned'" + + SecRule IP:REPUTATION_SCORE "@ge 100" \ + "id:100099,phase:1,deny,status:403,log,\ + msg:'Critical reputation score',\ + setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'" + + SecRule TX:SQL_INJECTION_SCORE "@ge 5" \ + "id:100001,phase:2,deny,status:403,log,\ + msg:'SQL Injection detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + + SecRule TX:XSS_SCORE "@ge 5" \ + "id:100010,phase:2,deny,status:403,log,\ + msg:'XSS detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + + SecRule TX:RCE_SCORE "@ge 5" \ + "id:100020,phase:2,deny,status:403,log,\ + msg:'RCE detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=259200'" + + SecRule TX:LFI_SCORE "@ge 5" \ + "id:100030,phase:2,deny,status:403,log,\ + msg:'LFI detected',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + + SecRule TX:INBOUND_ANOMALY_SCORE "@ge 20" \ + "id:100060,phase:2,deny,status:403,log,\ + msg:'Critical anomaly score',\ + setvar:'ip.banned=1',expirevar:'ip.banned=172800'" + + # Suppressions CRS pour les routes légitimes de l'app démo (mêmes IDs + # que l'ancienne config coraza-http-wasm, voir + # deploy/chart-gestion/traefik/templates/middleware-waf.yaml — conservé + # à titre d'historique, plus utilisé, coraza-waf retiré de la chaîne). + SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \ + "id:399001,phase:2,nolog,pass,ctl:ruleRemoveById=932235" + SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \ + "id:399002,phase:2,nolog,pass,ctl:ruleRemoveById=920120,ctl:ruleRemoveById=920121" + SecRule REQUEST_URI "@streq /api/v1/panier/remove" \ + "id:399010,phase:1,nolog,pass,ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350" + SecRule REQUEST_URI "@streq /api/v1/panier/clear" \ + "id:399011,phase:1,nolog,pass,ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350" + + # Fichiers uploadés — WAF désactivé (lecture seule, contenu déjà validé + # à l'upload). + SecRule REQUEST_URI "@beginsWith /uploads/" \ + "id:1000,phase:1,pass,nolog,ctl:ruleEngine=Off" + + # Rate limit basique par IP (indépendant du RateLimit du control-plane + # sur /auth/login, et du middleware rate-limit de Traefik en aval). + SecAction \ + "id:400161,phase:1,nolog,pass,\ + setvar:'ip.request_window_1sec=+1',\ + expirevar:'ip.request_window_1sec=1'" + + SecRule IP:REQUEST_WINDOW_1SEC "@gt 20" \ + "id:400160,phase:1,deny,status:429,log,\ + msg:'Too many requests'" diff --git a/deploy/chart-gestion/waf-cluster/templates/deployment.yaml b/deploy/chart-gestion/waf-cluster/templates/deployment.yaml new file mode 100644 index 0000000..eefe88e --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/templates/deployment.yaml @@ -0,0 +1,105 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "waf-cluster.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "waf-cluster.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "waf-cluster.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "waf-cluster.selectorLabels" . | nindent 8 }} + annotations: + # Force un rollout des pods quand la ConfigMap (nginx.conf, + # custom-rules.conf) change — sinon nginx garde l'ancienne config en + # mémoire jusqu'au prochain redémarrage manuel (les changements de + # ConfigMap seule ne déclenchent pas de rollout par défaut). + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + # Pas de readOnlyRootFilesystem ici : l'image owasp/modsecurity-crs + # écrit à plusieurs endroits internes à /etc/nginx/modsec (activation + # des plugins CRS, génération de la config paranoia/anomaly) au + # démarrage — voir les logs "Running CRS plugin activation" / + # "Running CRS rule configuration". Même posture que docker/waf + # (omnex-prod), qui tourne avec cette même image sans souci. À + # durcir plus tard une fois le mapping exact des chemins écrits + # identifié (cf. la mésaventure readOnlyRootFilesystem sur le + # frontend, deploy/chart-gestion/frontend). + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + seccompProfile: + type: RuntimeDefault + containers: + - name: waf + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + env: + - name: DISABLE_MODSEC_ENV_SUBST + value: "true" + - name: PARANOIA + value: "{{ .Values.waf.paranoia }}" + - name: ANOMALY_INBOUND + value: "{{ .Values.waf.anomalyInbound }}" + - name: ANOMALY_OUTBOUND + value: "{{ .Values.waf.anomalyOutbound }}" + - name: MODSEC_AUDIT_LOG + value: "/var/log/modsec/modsec_audit.log" + ports: + - name: http + containerPort: 80 + protocol: TCP + - name: https + containerPort: 443 + protocol: TCP + volumeMounts: + - name: conf + mountPath: /etc/nginx/conf.d/app.conf + subPath: app.conf + - name: conf + mountPath: /etc/nginx/modsec/custom-rules.conf + subPath: custom-rules.conf + - name: certs + mountPath: /etc/nginx/certs + readOnly: true + - name: modsec-log + mountPath: /var/log/modsec + livenessProbe: + httpGet: + path: /healthz + port: https + scheme: HTTPS + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: https + scheme: HTTPS + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: conf + configMap: + name: {{ include "waf-cluster.fullname" . }}-conf + - name: certs + secret: + secretName: {{ .Values.tls.secretName }} + - name: modsec-log + emptyDir: {} diff --git a/deploy/chart-gestion/waf-cluster/templates/service.yaml b/deploy/chart-gestion/waf-cluster/templates/service.yaml new file mode 100644 index 0000000..47b2291 --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "waf-cluster.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "waf-cluster.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + # Cluster (pas Local) : même raison que Traefik (voir + # deploy/chart-gestion/traefik/values.yaml) — bare-metal multi-nœuds, tout + # nœud contacté doit pouvoir router vers le pod WAF où qu'il tourne. + externalTrafficPolicy: Cluster + selector: + {{- include "waf-cluster.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + - name: https + port: 443 + targetPort: https + protocol: TCP diff --git a/deploy/chart-gestion/waf-cluster/values.yaml b/deploy/chart-gestion/waf-cluster/values.yaml new file mode 100644 index 0000000..4d24fc6 --- /dev/null +++ b/deploy/chart-gestion/waf-cluster/values.yaml @@ -0,0 +1,45 @@ +# WAF partagé (ModSecurity natif, nginx) devant le Traefik partagé — un seul +# déploiement pour toutes les démos, comme deploy/chart-gestion/traefik. +# Doit être installé dans le MÊME namespace que le Traefik partagé (accès au +# Secret TLS wildcard, pas de partage de Secret cross-namespace en k8s). + +replicaCount: 2 + +image: + repository: owasp/modsecurity-crs + tag: nginx-alpine + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# Certificat wildcard émis par cert-manager (voir deploy/cert-manager/), même +# Secret que consomme le TLSStore de Traefik (templates/tlsstore.yaml). +tls: + secretName: wildcard-demo-tls + +# Service + port internes du Traefik partagé (voir deploy/chart-gestion/traefik) : +# ce WAF termine le TLS public puis rouvre une connexion TLS interne vers +# l'entryPoint websecure de Traefik (aucune modification requise côté +# IngressRoute/entryPoints — Traefik ne voit aucune différence). +upstream: + host: gestion-traefik.traefik.svc.cluster.local + # Port du Service Kubernetes "gestion-traefik" (websecure), pas le port + # interne du conteneur (8443, le Service l'abstrait derrière son propre + # port 443 — voir kubectl -n traefik get svc gestion-traefik). + port: 443 + +waf: + paranoia: 2 + anomalyInbound: 5 + anomalyOutbound: 4 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +service: + type: LoadBalancer diff --git a/deploy/rbac/control-plane.yml b/deploy/rbac/control-plane.yml new file mode 100644 index 0000000..0a97c0f --- /dev/null +++ b/deploy/rbac/control-plane.yml @@ -0,0 +1,197 @@ +# RBAC du control-plane (worker Go qui provisionne les démos) — moindre +# privilège : ce ServiceAccount N'A PAS accès aux Secrets/Deployments/etc. +# des autres namespaces (cert-manager, longhorn-system, kube-system...). +# +# Principe : +# - omnex-namespace-manager (ClusterRole) : gérer les objets Namespace eux- +# mêmes. Cluster-scoped par nature (RBAC ne permet pas de restreindre par +# préfixe de nom "demo-*"/"premium-*"). +# - omnex-demo-workload-manager (ClusterRole) : tout ce dont les charts +# Helm ont besoin (Deployment, StatefulSet, Secret, ConfigMap, PVC, +# NetworkPolicy, ResourceQuota, LimitRange, HPA, les CRD Traefik). JAMAIS +# liée cluster-wide — seulement via un RoleBinding créé par le code Go +# lui-même dans CHAQUE namespace qu'il crée (voir +# control-plane/api/internal/demos/helm_provisioner.go:createNamespace/ +# ensureWorkloadRoleBinding). C'est ce qui borne l'accès à un seul +# namespace à la fois. +# - omnex-rbac-binder (ClusterRole) : permet au ServiceAccount de créer ce +# RoleBinding lui-même, dans n'importe quel namespace (obligatoire : +# les namespaces de démo n'existent pas encore au moment où ce fichier +# est appliqué). Verbe "bind" scopé par resourceName à +# omnex-demo-workload-manager UNIQUEMENT — mécanisme RBAC standard +# anti-escalade : posséder "bind" sur une ClusterRole précise autorise à +# la référencer dans un RoleBinding sans posséder soi-même les droits +# qu'elle contient. Le ServiceAccount ne peut donc lier AUCUNE autre +# ClusterRole (ex: cluster-admin) à lui-même. +apiVersion: v1 +kind: Namespace +metadata: + name: omnex-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: omnex-control-plane + namespace: omnex-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: omnex-demo-workload-manager +rules: + - apiGroups: [""] + resources: + - configmaps + - secrets + - services + - persistentvolumeclaims + - resourcequotas + - limitranges + # Le subchart officiel traefik/traefik crée son propre ServiceAccount + # (nom = release Helm) — pas utilisé par nos propres charts + # (backend/frontend/postgresql/redis/lbtelegram), mais nécessaire pour + # que "helm install traefik" passe. + - serviceaccounts + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [""] + resources: [pods] + verbs: [get, list, watch] + - apiGroups: [""] + # Exec dans le pod postgres pour pg_dump/restore lors d'un passage en + # premium (voir HelmProvisioner.execInPod / migratePostgresData). + resources: [pods/exec] + verbs: [create] + - apiGroups: ["metrics.k8s.io"] + # Usage CPU/mémoire live affiché dans le back-office (voir + # HelmProvisioner.GetResourceState) — best-effort côté Go (montre juste + # la phase du pod si absent), mais sans ce droit c'est TOUJOURS absent + # (Forbidden, pas juste "metrics-server indisponible"). + resources: [pods] + verbs: [get, list] + - apiGroups: ["apps"] + resources: [deployments, statefulsets] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: ["apps"] + # Lecture seule : "helm --wait" liste les ReplicaSet d'un Deployment pour + # vérifier l'état du rollout (jamais créés/gérés directement par nous). + resources: [replicasets] + verbs: [get, list, watch] + - apiGroups: ["autoscaling"] + resources: [horizontalpodautoscalers] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: ["networking.k8s.io"] + resources: [networkpolicies] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: ["traefik.io"] + resources: [ingressroutes, middlewares, tlsstores] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: omnex-namespace-manager +rules: + - apiGroups: [""] + resources: [namespaces] + verbs: [get, list, watch, create, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: omnex-rbac-binder +rules: + - apiGroups: ["rbac.authorization.k8s.io"] + resources: [rolebindings] + verbs: [get, list, create] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: [clusterroles] + resourceNames: [omnex-demo-workload-manager] + verbs: [bind] + # Lecture seule, cluster-wide : nécessaire pour que "helm upgrade --install" + # du chart traefik partagé (voir EnsureSharedInfra) puisse vérifier l'état + # du ClusterRole/ClusterRoleBinding que le subchart officiel traefik/traefik + # crée pour SA PROPRE identité ("gestion-traefik-traefik", pas la nôtre). + # Ne permet ni de créer ni de modifier quoi que ce soit — seulement de + # constater qu'ils existent déjà (installés une fois par un opérateur admin, + # voir le bootstrap manuel documenté dans le plan de déploiement). + - apiGroups: ["rbac.authorization.k8s.io"] + resources: [clusterroles, clusterrolebindings] + verbs: [get, list, watch] + # Même besoin : le subchart officiel traefik/traefik crée aussi son propre + # IngressClass (ressource cluster-scoped), déjà installée une fois par un + # opérateur admin — lecture seule ici aussi. + - apiGroups: ["networking.k8s.io"] + resources: [ingressclasses] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: omnex-control-plane-namespaces +subjects: + - kind: ServiceAccount + name: omnex-control-plane + namespace: omnex-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: omnex-namespace-manager +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: omnex-control-plane-rbac-binder +subjects: + - kind: ServiceAccount + name: omnex-control-plane + namespace: omnex-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: omnex-rbac-binder +--- +# Token longue durée : le control-plane tourne en docker-compose (hors du +# cluster) avec un kubeconfig statique monté en fichier, pas de rotation +# automatique de type projected-token. Voir ansible/playbook-deploy-site.yml. +apiVersion: v1 +kind: Secret +metadata: + name: omnex-control-plane-token + namespace: omnex-system + annotations: + kubernetes.io/service-account.name: omnex-control-plane +type: kubernetes.io/service-account-token +--- +# Role namespacé (pas de RoleBinding auto-créé ici : "velero" est un +# namespace fixe préexistant, pas un namespace de démo créé dynamiquement +# par le control-plane — pas besoin du mécanisme "bind" utilisé pour +# omnex-demo-workload-manager). Portée strictement limitée aux Schedule/ +# Backup Velero — aucun accès aux Secrets (dont cloud-credentials, la clé +# du bucket S3) ni au reste du namespace velero. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: omnex-control-plane + namespace: velero +rules: + - apiGroups: ["velero.io"] + # Un Schedule + les Backup qu'il crée par démo (voir + # HelmProvisioner.ensureDemoBackupSchedule/deleteDemoBackups) — jamais + # de wildcard multi-namespace, pour pouvoir supprimer les sauvegardes + # d'UNE démo sans toucher aux autres. + resources: [schedules, backups] + verbs: [get, list, watch, create, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: omnex-control-plane + namespace: velero +subjects: + - kind: ServiceAccount + name: omnex-control-plane + namespace: omnex-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: omnex-control-plane diff --git a/deploy/velero/schedule.yml b/deploy/velero/schedule.yml index 4dc225d..46afcb8 100644 --- a/deploy/velero/schedule.yml +++ b/deploy/velero/schedule.yml @@ -1,36 +1,24 @@ -# Sauvegarde quotidienne de tous les namespaces de démo (postgres, redis, -# uploads, et le reste : Secrets/ConfigMaps/Deployments pour une -# restauration complète) vers le bucket S3 configuré dans values.yaml. -# S'applique par défaut à TOUT namespace non exclu ci-dessous — une nouvelle -# démo créée dynamiquement (demo-xxxxx, premium-xxxxx) est donc couverte -# automatiquement, sans modifier ce fichier. +# Il n'y a plus de Schedule Velero statique/global ici (l'ancienne version de +# ce fichier en définissait deux : demos-daily et demos-frequent, couvrant +# TOUS les namespaces en un seul objet Backup par exécution). # -# À appliquer après l'installation de Velero (voir values.yaml) : -# kubectl apply -f deploy/velero/schedule.yml -apiVersion: velero.io/v1 -kind: Schedule -metadata: - name: demos-daily - namespace: velero -spec: - # 03h00 UTC, tous les jours — heure creuse pour ce cluster. - schedule: "0 3 * * *" - template: - includedNamespaces: - - "*" - excludedNamespaces: - - kube-system - - kube-public - - kube-node-lease - - default - - velero - - traefik - - longhorn-system - - cert-manager - # Sauvegarde le contenu réel des PVC (postgres/redis/uploads) via - # File System Backup — voir values.yaml. - defaultVolumesToFsBackup: true - storageLocation: default - # Conservation 30 jours : au-delà, la sauvegarde devient éligible à la - # purge automatique (garbage collection Velero). - ttl: 720h0m0s +# Pourquoi ce changement : Velero ne permet de supprimer qu'un objet Backup +# ENTIER, jamais une portion (ex: "juste les données de la démo X"). Avec un +# planning global multi-namespace, détruire une démo n'aurait donc jamais pu +# supprimer ses sauvegardes sans supprimer aussi celles des autres démos +# présentes dans le même Backup. +# +# À la place, le control-plane crée maintenant un Schedule Velero DÉDIÉ par +# démo (voir HelmProvisioner.ensureDemoBackupSchedule dans +# control-plane/api/internal/demos/helm_provisioner.go) : +# - à la création de la démo (Provision) et lors d'un passage en premium +# (MigrateToPremiumNamespace, un nouveau namespace = un nouveau planning) ; +# - toutes les 15 min, conservées 24h (fenêtre de perte de données +# minimale en cas de problème sur le cluster) ; +# - supprimé avec ses Backup déjà pris (HelmProvisioner.deleteDemoBackups) +# quand la démo est détruite — expiration du TTL ou destruction forcée +# par un admin, les deux passent par Service.Delete → Teardown. +# +# Rien à appliquer manuellement ici : ces Schedule sont gérés entièrement +# par le control-plane, dans le namespace "velero", nommés +# "demo-backup-". diff --git a/deploy/velero/values.yaml b/deploy/velero/values.yaml index 62bf847..c812916 100644 --- a/deploy/velero/values.yaml +++ b/deploy/velero/values.yaml @@ -25,7 +25,8 @@ # helm install velero vmware-tanzu/velero \ # --namespace velero --version 12.1.0 \ # -f deploy/velero/values.yaml -# kubectl apply -f deploy/velero/schedule.yml +# Rien d'autre à appliquer : les plannings de sauvegarde sont créés +# dynamiquement par démo par le control-plane, voir deploy/velero/schedule.yml. # # 5. Sécurité du bucket — à vérifier CÔTÉ RustFS/MinIO (rien de tout ça ne # se configure depuis ce repo) : chaque sauvegarde contient les Secrets @@ -68,21 +69,23 @@ configuration: - name: default provider: aws # Nom du bucket créé à l'étape 1. Requis. - bucket: "CHANGE_ME_bucket_name" + bucket: "backup-omnex" default: true credential: name: cloud-credentials key: cloud config: region: "us-east-1" - s3Url: "CHANGE_ME_https://votre-endpoint-rustfs-ou-minio" + s3Url: "https://backup-omnex.uber-stup.club" s3ForcePathStyle: "true" insecureSkipTLSVerify: "false" - # Chiffrement côté serveur du contenu des sauvegardes (SSE-S3, - # AES256 géré par le bucket) — RustFS et MinIO le supportent tous - # les deux. Ne dispense pas de vérifier le chiffrement at-rest réel - # du stockage sous-jacent (voir étape 5 ci-dessus). - serverSideEncryption: "AES256" + # Pas de serverSideEncryption ici : ce RustFS refuse le SSE-S3 tant + # que RUSTFS_SSE_S3_MASTER_KEY (clé base64 32 octets) n'est pas + # configurée côté serveur ("InvalidRequest: SSE-S3 requires + # RUSTFS_SSE_S3_MASTER_KEY..."), réglage hors de portée depuis ce + # repo. Le transport reste chiffré (HTTPS) ; pour du chiffrement + # at-rest, configurer cette variable côté serveur RustFS puis + # remettre serverSideEncryption: "AES256" ici. volumeSnapshotLocation: []