diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index 4a2ecf9..6bf32dc 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -117,6 +117,14 @@ func main() { log.Fatalf("helm provisioner: %v", err) } + // Traefik partagé + WAF Coraza : installé une fois pour toutes les démos + // (idempotent). Non-fatal : si ça échoue, l'API démarre quand même, mais + // les démos provisionnées ensuite ne seront ni routées ni protégées tant + // que ce n'est pas corrigé. + if err := helmProv.EnsureSharedInfra(); err != nil { + log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err) + } + // Service de démos avec le provisioner Helm demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{ BaseDomain: cfg.DemoDomain, diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index f2a136d..0539b07 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -68,12 +68,22 @@ func NewHelmProvisioner( }, nil } +// sharedTraefikNamespace / sharedTraefikRelease : le routeur Traefik + WAF +// Coraza est installé UNE SEULE FOIS pour toutes les démos (voir +// EnsureSharedInfra), jamais par démo — chaque démo installe seulement le +// chart "ingressroute" qui la raccorde à ce Traefik partagé. +const ( + sharedTraefikNamespace = "traefik" + sharedTraefikRelease = "gestion-traefik" +) + // Provision déploie une démo avec Helm : // 1. Crée le namespace // 2. Installe PostgreSQL pour la démo // 3. Installe Redis pour la démo // 4. Installe le chart backend // 5. Installe le chart frontend +// 6. Raccorde la démo au Traefik partagé (WAF + routage HTTPS du sous-domaine) func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error { // Créer le namespace if err := h.createNamespace(d.Namespace); err != nil { @@ -85,6 +95,7 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error redisValues := h.buildRedisValues(d) backendValues := h.buildBackendValues(d, resources) frontendValues := h.buildFrontendValues(d) + ingressValues := h.buildIngressRouteValues(d) // Installer PostgreSQL (nécessaire pour le backend) if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil { @@ -110,6 +121,13 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error return fmt.Errorf("déploiement frontend: %w", err) } + // Raccorder au Traefik partagé : sans ça la démo n'est ni routée ni + // protégée par le WAF, même si backend/frontend tournent. + if err := h.installChart(d.Namespace, "ingressroute", ingressValues); err != nil { + h.deleteNamespace(d.Namespace) + return fmt.Errorf("déploiement ingressroute: %w", err) + } + // Attendre que les pods soient prêts if err := h.waitForRollout(d.Namespace); err != nil { log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err) @@ -118,6 +136,33 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error return nil } +// EnsureSharedInfra installe (ou met à jour, de façon idempotente) le +// Traefik partagé + WAF Coraza. À appeler une fois au démarrage de l'API, +// avant toute démo — voir cmd/api/main.go. Contrairement à Provision, ceci +// n'est PAS répété par démo. +func (h *HelmProvisioner) EnsureSharedInfra() error { + if err := h.createNamespace(sharedTraefikNamespace); err != nil { + return fmt.Errorf("création namespace %s: %w", sharedTraefikNamespace, err) + } + + chartPath := filepath.Join(h.chartsDir, "traefik") + + // Le chart traefik dépend du chart officiel traefik/traefik (subchart) : + // il faut le récupérer avant l'install (nécessite un accès internet + // sortant depuis ce pod, comme pour le plugin Coraza téléchargé par + // Traefik lui-même au démarrage). + if err := h.runHelmDependencyUpdate(chartPath); err != nil { + return fmt.Errorf("helm dependency update traefik: %w", err) + } + + if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedTraefikRelease, "traefik", nil); err != nil { + return fmt.Errorf("déploiement traefik partagé: %w", err) + } + + log.Printf("Traefik partagé (WAF Coraza + rate-limit) prêt dans le namespace %s", sharedTraefikNamespace) + return nil +} + // Teardown détruit une démo en supprimant son namespace (cascading). func (h *HelmProvisioner) Teardown(d Demo) error { return h.deleteNamespace(d.Namespace) @@ -148,42 +193,93 @@ func (h *HelmProvisioner) deleteNamespace(name string) error { }) } -// installChart installe un chart Helm avec des valeurs personnalisées. +// installChart installe (première install, jamais réappliqué) un chart Helm +// avec des valeurs personnalisées. Release name : "-". +// Adapté aux charts par démo (namespace toujours neuf). func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error { - chartPath := filepath.Join(h.chartsDir, chartName) - - // Créer un fichier values.yaml temporaire - valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName)) - valuesYAML, err := yaml.Marshal(values) + release := fmt.Sprintf("%s-%s", namespace, chartName) + valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) if err != nil { - return fmt.Errorf("marshal YAML: %w", err) + return err } - if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil { - return fmt.Errorf("write values file: %w", err) - } - defer os.Remove(valuesFile) + defer cleanup() - // Construire la commande helm install args := []string{ "install", - fmt.Sprintf("%s-%s", namespace, chartName), - chartPath, + release, + filepath.Join(h.chartsDir, chartName), "--namespace", namespace, "--values", valuesFile, "--wait", "--timeout", "5m", } + if err := h.runHelm(args...); err != nil { + return fmt.Errorf("helm install %s: %w", chartName, err) + } + log.Printf("Chart %s installé dans %s", chartName, namespace) + return nil +} + +// upgradeInstallChart installe ou met à jour un chart de façon idempotente +// (helm upgrade --install) — pour l'infra partagée (Traefik), appelée à +// chaque démarrage de l'API et qui ne doit pas échouer si déjà en place. +func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName string, values map[string]interface{}) error { + args := []string{ + "upgrade", release, + filepath.Join(h.chartsDir, chartName), + "--install", + "--namespace", namespace, + "--wait", + "--timeout", "5m", + } + if values != nil { + valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) + if err != nil { + return err + } + defer cleanup() + args = append(args, "--values", valuesFile) + } + if err := h.runHelm(args...); err != nil { + return fmt.Errorf("helm upgrade --install %s: %w", chartName, err) + } + + log.Printf("Chart %s (release %s) à jour dans %s", chartName, release, namespace) + return nil +} + +// runHelmDependencyUpdate télécharge les subcharts déclarés dans +// Chart.yaml (dependencies) — requis avant d'installer un chart qui, comme +// traefik, dépend d'un chart tiers. +func (h *HelmProvisioner) runHelmDependencyUpdate(chartPath string) error { + return h.runHelm("dependency", "update", chartPath) +} + +// writeValuesFile sérialise des valeurs Helm dans un fichier temporaire et +// retourne son chemin + une fonction de nettoyage à appeler en defer. +func (h *HelmProvisioner) writeValuesFile(namespace, chartName string, values map[string]interface{}) (string, func(), error) { + valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName)) + valuesYAML, err := yaml.Marshal(values) + if err != nil { + return "", nil, fmt.Errorf("marshal YAML: %w", err) + } + if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil { + return "", nil, fmt.Errorf("write values file: %w", err) + } + return valuesFile, func() { os.Remove(valuesFile) }, nil +} + +// runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur. +func (h *HelmProvisioner) runHelm(args ...string) error { cmd := exec.Command(h.helmPath, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("helm install %s: %v (stdout: %s, stderr: %s)", chartName, err, stdout.String(), stderr.String()) + return fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String()) } - - log.Printf("Chart %s installé dans %s", chartName, namespace) return nil } @@ -244,7 +340,9 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc // buildFrontendValues construit les valeurs pour le chart frontend. func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { - backendURL := fmt.Sprintf("http://%s-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace) + // Nom réel du Service backend : "-backend" (release helm) + + // "-gestion-backend" (nom du chart, ajouté par le helper fullname). + backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace) // Parser l'image frontend pour séparer repository et tag frontendRepo, frontendTag := parseImage(h.frontendImage) @@ -266,6 +364,15 @@ func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { } } +// buildIngressRouteValues construit les valeurs pour le chart ingressroute, +// qui raccorde cette démo au Traefik partagé (WAF + routage HTTPS). +func (h *HelmProvisioner) buildIngressRouteValues(d Demo) map[string]interface{} { + return map[string]interface{}{ + "host": fmt.Sprintf("%s.%s", d.Namespace, h.baseDomain), + "traefikNamespace": sharedTraefikNamespace, + } +} + // buildSecrets extrait les références des secrets des ressources externes. func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string { secrets := map[string]string{} diff --git a/deploy/beszel-agent-secret.yml b/deploy/beszel-agent-secret.yml new file mode 100644 index 0000000..cbedc63 --- /dev/null +++ b/deploy/beszel-agent-secret.yml @@ -0,0 +1,15 @@ +# Identifiants à récupérer dans le Hub Beszel : Add System > Universal token. +# Le token universel expire au bout d'1h mais permet l'auto-enregistrement de +# TOUS les agents du DaemonSet sans les déclarer un par un dans le Hub ; une +# fois qu'un agent s'est enregistré, il garde son identité même après +# l'expiration du token (donc pas besoin de le renouveler après coup). +apiVersion: v1 +kind: Secret +metadata: + name: beszel-agent-hub + namespace: monitoring +type: Opaque +stringData: + HUB_URL: "" # ex: https://beszel.exemple.com + TOKEN: "" # token universel généré dans le Hub + KEY: "" # clé publique SSH affichée par le Hub diff --git a/deploy/beszel-namespace.yml b/deploy/beszel-namespace.yml new file mode 100644 index 0000000..d325236 --- /dev/null +++ b/deploy/beszel-namespace.yml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring diff --git a/deploy/cert-manager/cluster-issuer.yml b/deploy/cert-manager/cluster-issuer.yml new file mode 100644 index 0000000..39b15c6 --- /dev/null +++ b/deploy/cert-manager/cluster-issuer.yml @@ -0,0 +1,65 @@ +# Prérequis (à faire une seule fois, hors de ces manifestes) : +# helm repo add jetstack https://charts.jetstack.io +# helm install cert-manager jetstack/cert-manager \ +# --namespace cert-manager --create-namespace --set crds.enabled=true +# +# Un certificat wildcard (*.demo.demo-uber.club) ne peut être émis que via un +# challenge DNS-01 (le challenge HTTP-01 ne supporte pas les wildcards) : il +# faut donc que cert-manager puisse créer des enregistrements TXT chez votre +# fournisseur DNS. Exemple ci-dessous avec Cloudflare (le plus courant) — +# remplacez le bloc `cloudflare:` par le solver de votre fournisseur si ce +# n'est pas celui-ci (route53, digitalocean, ovh via webhook, etc.). +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-api-token + namespace: cert-manager +type: Opaque +stringData: + # Token Cloudflare avec la permission "Zone:DNS:Edit" sur la zone demo-uber.club. + # https://dash.cloudflare.com/profile/api-tokens + api-token: "" # À REMPLIR avant application + +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-dns +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@demo-uber.club # À adapter + privateKeySecretRef: + name: letsencrypt-dns-account-key + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cloudflare-api-token + key: api-token + selector: + dnsZones: + - "demo-uber.club" + +--- +# Issuer de test (ne compte pas dans les limites de rate-limit de Let's Encrypt +# prod — utile pour valider la chaîne DNS-01 avant de basculer sur letsencrypt-dns). +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-dns-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: admin@demo-uber.club # À adapter + privateKeySecretRef: + name: letsencrypt-dns-staging-account-key + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cloudflare-api-token + key: api-token + selector: + dnsZones: + - "demo-uber.club" diff --git a/deploy/cert-manager/wildcard-certificate.yml b/deploy/cert-manager/wildcard-certificate.yml new file mode 100644 index 0000000..ed8bd6e --- /dev/null +++ b/deploy/cert-manager/wildcard-certificate.yml @@ -0,0 +1,19 @@ +# Un seul certificat wildcard, renouvelé automatiquement par cert-manager +# (par défaut ~30 jours avant expiration), stocké dans le Secret ci-dessous. +# Le chart traefik (voir deploy/chart-gestion/traefik) le consomme comme +# certificat par défaut de son TLSStore : aucune démo n'a besoin de son +# propre certificat, toutes partagent celui-ci. +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: wildcard-demo-tls + namespace: traefik # doit être le même namespace que le déploiement Traefik partagé +spec: + secretName: wildcard-demo-tls + issuerRef: + name: letsencrypt-dns + kind: ClusterIssuer + commonName: "demo.demo-uber.club" + dnsNames: + - "demo.demo-uber.club" + - "*.demo.demo-uber.club" diff --git a/deploy/chart-gestion/backend/values.yaml b/deploy/chart-gestion/backend/values.yaml index 5900f97..e568757 100644 --- a/deploy/chart-gestion/backend/values.yaml +++ b/deploy/chart-gestion/backend/values.yaml @@ -61,6 +61,8 @@ podSecurityContext: runAsUser: 101 runAsGroup: 101 fsGroup: 101 + seccompProfile: + type: RuntimeDefault securityContext: allowPrivilegeEscalation: false diff --git a/deploy/chart-gestion/frontend/values.yaml b/deploy/chart-gestion/frontend/values.yaml index 3ccae34..8975942 100644 --- a/deploy/chart-gestion/frontend/values.yaml +++ b/deploy/chart-gestion/frontend/values.yaml @@ -29,6 +29,8 @@ podSecurityContext: runAsNonRoot: true runAsUser: 101 runAsGroup: 101 + seccompProfile: + type: RuntimeDefault securityContext: allowPrivilegeEscalation: false diff --git a/deploy/chart-gestion/ingressroute/Chart.yaml b/deploy/chart-gestion/ingressroute/Chart.yaml new file mode 100644 index 0000000..14eb44c --- /dev/null +++ b/deploy/chart-gestion/ingressroute/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: gestion-ingressroute +description: Routage Traefik d'une démo (Host dynamique -> Services de son propre namespace) — installé une fois par démo, pointe vers le Traefik partagé (deploy/chart-gestion/traefik) +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/chart-gestion/ingressroute/templates/_helpers.tpl b/deploy/chart-gestion/ingressroute/templates/_helpers.tpl new file mode 100644 index 0000000..abefac4 --- /dev/null +++ b/deploy/chart-gestion/ingressroute/templates/_helpers.tpl @@ -0,0 +1,14 @@ +{{- define "ingressroute.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "ingressroute.backendService" -}} +{{- .Values.backend.serviceName | default (printf "%s-backend-gestion-backend" .Release.Namespace) }} +{{- end }} + +{{- define "ingressroute.frontendService" -}} +{{- .Values.frontend.serviceName | default (printf "%s-frontend-gestion-frontend" .Release.Namespace) }} +{{- end }} diff --git a/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml b/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml new file mode 100644 index 0000000..9cceb59 --- /dev/null +++ b/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml @@ -0,0 +1,53 @@ +{{- if not .Values.host }} +{{- fail "ingressroute: .Values.host est requis (ex: demo-abc123.demo.demo-uber.club)" }} +{{- end }} +# Routage HTTPS de cette démo. Le TLS n'est pas précisé : Traefik utilise +# automatiquement le certificat wildcard par défaut du TLSStore partagé +# (voir deploy/chart-gestion/traefik/templates/tlsstore.yaml). Le redirect +# HTTP -> HTTPS est géré au niveau de l'entrypoint Traefik (pas dupliqué ici). +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ .Release.Namespace }}-routes + namespace: {{ .Release.Namespace }} + labels: + {{- include "ingressroute.labels" . | nindent 4 }} +spec: + entryPoints: + - websecure + routes: + # API backend — WAF + headers + rate limit (middlewares partagés, définis + # dans le namespace du Traefik commun, référencés cross-namespace). + - match: Host(`{{ .Values.host }}`) && (PathPrefix(`/api/`) || PathPrefix(`/webhook/`)) + kind: Rule + priority: 20 + middlewares: + - name: waf-chain + namespace: {{ .Values.traefikNamespace }} + services: + - name: {{ include "ingressroute.backendService" . }} + port: {{ .Values.backend.servicePort }} + + # Fichiers uploadés — WAF désactivé dans les règles Coraza pour ce path + - match: Host(`{{ .Values.host }}`) && PathPrefix(`/uploads/`) + kind: Rule + priority: 15 + middlewares: + - name: security-headers + namespace: {{ .Values.traefikNamespace }} + services: + - name: {{ include "ingressroute.backendService" . }} + port: {{ .Values.backend.servicePort }} + + # Frontend SPA — WAF + headers + rate limit + - match: Host(`{{ .Values.host }}`) + kind: Rule + priority: 1 + middlewares: + - name: waf-chain + namespace: {{ .Values.traefikNamespace }} + services: + - name: {{ include "ingressroute.frontendService" . }} + port: {{ .Values.frontend.servicePort }} + + tls: {} diff --git a/deploy/chart-gestion/ingressroute/values.yaml b/deploy/chart-gestion/ingressroute/values.yaml new file mode 100644 index 0000000..d1ff19a --- /dev/null +++ b/deploy/chart-gestion/ingressroute/values.yaml @@ -0,0 +1,21 @@ +# Sous-domaine complet de la démo, ex: demo-abc123.demo.demo-uber.club +# (doit correspondre au wildcard *.demo.demo-uber.club émis par cert-manager +# et à l'URL calculée par le control-plane pour cette démo — voir +# control-plane/api/internal/demos/service.go). +host: "" + +# Namespace où tourne le Traefik partagé et ses Middlewares (waf-chain, +# rate-limit, coraza-waf, security-headers) — voir deploy/chart-gestion/traefik. +traefikNamespace: traefik + +# Services de CE namespace de démo (mêmes noms que ceux générés par les +# charts backend/frontend : -backend-gestion-backend, etc.) +# Laissés vides par défaut : calculés automatiquement à partir de +# .Release.Namespace dans le template si non surchargés ici. +backend: + serviceName: "" + servicePort: 8080 + +frontend: + serviceName: "" + servicePort: 80 diff --git a/deploy/chart-gestion/network-policy/Chart.yaml b/deploy/chart-gestion/network-policy/Chart.yaml new file mode 100644 index 0000000..9b1bcb5 --- /dev/null +++ b/deploy/chart-gestion/network-policy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: gestion-network-policy +description: Isolation réseau stricte d'une démo — deny-by-default, aucune communication inter-démos, pas d'accès à l'API Kubernetes ni à kube-dns au-delà du port 53 +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/chart-gestion/network-policy/templates/networkpolicy.yaml b/deploy/chart-gestion/network-policy/templates/networkpolicy.yaml new file mode 100644 index 0000000..9feb26f --- /dev/null +++ b/deploy/chart-gestion/network-policy/templates/networkpolicy.yaml @@ -0,0 +1,106 @@ +# ------------------------------------------------------------------- +# 1. Deny-by-default : sans règle explicite plus bas, AUCUN trafic entrant +# ni sortant n'est autorisé pour les pods de ce namespace. C'est ce qui +# empêche par défaut toute communication vers un autre namespace de démo +# et vers l'API Kubernetes. +# ------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + +--- +# ------------------------------------------------------------------- +# 2. Trafic intra-démo : frontend <-> backend <-> postgresql/redis, dans +# CE namespace uniquement (le podSelector du namespaceSelector par défaut +# de Kubernetes limite déjà à ce namespace : pas de namespaceSelector ici +# équivaut à "même namespace"). +# ------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-intra-namespace +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} + egress: + - to: + - podSelector: {} + +--- +# ------------------------------------------------------------------- +# 3. Ingress externe autorisé : uniquement depuis le Traefik partagé +# (aucune autre démo, aucun autre client, ne peut ouvrir de connexion +# directe vers les pods de cette démo). +# ------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-from-traefik +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.traefikNamespace }} + +--- +# ------------------------------------------------------------------- +# 4. DNS uniquement vers kube-system, port 53. C'est la SEULE ouverture vers +# kube-system : aucun accès à autre chose dans ce namespace, et l'API +# server (kubernetes.default.svc, namespace "default") n'est jamais +# autorisé nulle part dans ce chart -> bloqué par le deny-by-default. +# ------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-dns +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.kubeSystemNamespace }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + +--- +# ------------------------------------------------------------------- +# 5. Internet public sortant (webhooks Telegram/NowPayments, GPS TomTom...), +# en excluant les plages privées : ça couvre par construction l'IP de +# l'API server et celles des autres namespaces, sans avoir besoin de la +# connaître explicitement. +# ------------------------------------------------------------------- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-internet +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + {{- toYaml .Values.blockedEgressCIDRs | nindent 14 }} diff --git a/deploy/chart-gestion/network-policy/values.yaml b/deploy/chart-gestion/network-policy/values.yaml new file mode 100644 index 0000000..5cd68fc --- /dev/null +++ b/deploy/chart-gestion/network-policy/values.yaml @@ -0,0 +1,25 @@ +# IMPORTANT : les NetworkPolicy ne sont appliquées que si le CNI du cluster +# les supporte (Calico, Cilium, Weave...). Avec un CNI qui ne les enforce pas +# (ex: flannel seul), ce chart s'installe sans erreur mais n'isole RIEN — +# à vérifier avant de considérer l'isolation comme active. + +# Namespace où tourne CoreDNS (résolution de noms) — seul port autorisé en +# egress vers ce namespace : 53 (DNS). Aucun autre accès à kube-system, +# donc pas d'accès à l'API server (kubernetes.default.svc vit dans le +# namespace "default", pas dans kube-system, et n'est de toute façon jamais +# ajouté aux règles autorisées ci-dessous : bloqué par omission). +kubeSystemNamespace: kube-system + +# Namespace du Traefik partagé (voir deploy/chart-gestion/traefik) : seule +# origine externe autorisée à ouvrir une connexion ingress vers cette démo. +traefikNamespace: traefik + +# Plages privées exclues de l'egress "internet" autorisé (RFC1918) : empêche +# tout mouvement latéral vers d'autres pods/namespaces/nœuds/l'API server par +# IP directe, tout en laissant sortir vers l'internet public (Telegram, +# NowPayments, TomTom, etc.). Si le CIDR des pods/services de votre cluster +# ne tombe pas dans ces plages standard, ajoutez-le ici. +blockedEgressCIDRs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 diff --git a/deploy/chart-gestion/postgresql/templates/pvc.yaml b/deploy/chart-gestion/postgresql/templates/pvc.yaml index badd67e..e776ca6 100644 --- a/deploy/chart-gestion/postgresql/templates/pvc.yaml +++ b/deploy/chart-gestion/postgresql/templates/pvc.yaml @@ -1,3 +1,4 @@ +{{- if .Values.persistence.enabled }} apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -13,3 +14,4 @@ spec: {{- if .Values.persistence.storageClass }} storageClassName: {{ .Values.persistence.storageClass }} {{- end }} +{{- end }} diff --git a/deploy/chart-gestion/postgresql/templates/statefulset.yaml b/deploy/chart-gestion/postgresql/templates/statefulset.yaml index b099456..5f30770 100644 --- a/deploy/chart-gestion/postgresql/templates/statefulset.yaml +++ b/deploy/chart-gestion/postgresql/templates/statefulset.yaml @@ -63,8 +63,12 @@ spec: {{- toYaml .Values.resources | nindent 12 }} volumes: - name: data + {{- if .Values.persistence.enabled }} persistentVolumeClaim: claimName: {{ include "postgresql.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} - name: config configMap: name: {{ include "postgresql.fullname" . }}-config diff --git a/deploy/chart-gestion/postgresql/values.yaml b/deploy/chart-gestion/postgresql/values.yaml index 35522d9..5b63c97 100644 --- a/deploy/chart-gestion/postgresql/values.yaml +++ b/deploy/chart-gestion/postgresql/values.yaml @@ -10,6 +10,7 @@ auth: password: "" persistence: + enabled: true size: 10Gi storageClass: "" accessMode: ReadWriteOnce @@ -38,9 +39,12 @@ config: logMinDurationStatement: "1000" podSecurityContext: + runAsNonRoot: true runAsUser: 999 runAsGroup: 999 fsGroup: 999 + seccompProfile: + type: RuntimeDefault securityContext: allowPrivilegeEscalation: false diff --git a/deploy/chart-gestion/redis/templates/pvc.yaml b/deploy/chart-gestion/redis/templates/pvc.yaml index 776e7f2..4b403f5 100644 --- a/deploy/chart-gestion/redis/templates/pvc.yaml +++ b/deploy/chart-gestion/redis/templates/pvc.yaml @@ -1,3 +1,4 @@ +{{- if .Values.persistence.enabled }} apiVersion: v1 kind: PersistentVolumeClaim metadata: @@ -13,3 +14,4 @@ spec: {{- if .Values.persistence.storageClass }} storageClassName: {{ .Values.persistence.storageClass }} {{- end }} +{{- end }} diff --git a/deploy/chart-gestion/redis/templates/statefulset.yaml b/deploy/chart-gestion/redis/templates/statefulset.yaml index 0473c98..d36ad62 100644 --- a/deploy/chart-gestion/redis/templates/statefulset.yaml +++ b/deploy/chart-gestion/redis/templates/statefulset.yaml @@ -67,8 +67,12 @@ spec: {{- toYaml .Values.resources | nindent 12 }} volumes: - name: data + {{- if .Values.persistence.enabled }} persistentVolumeClaim: claimName: {{ include "redis.fullname" . }}-data + {{- else }} + emptyDir: {} + {{- end }} - name: config configMap: name: {{ include "redis.fullname" . }}-config diff --git a/deploy/chart-gestion/redis/values.yaml b/deploy/chart-gestion/redis/values.yaml index d982301..7956231 100644 --- a/deploy/chart-gestion/redis/values.yaml +++ b/deploy/chart-gestion/redis/values.yaml @@ -8,6 +8,7 @@ auth: password: "" persistence: + enabled: true size: 2Gi storageClass: "" accessMode: ReadWriteOnce @@ -34,9 +35,12 @@ config: save: "" podSecurityContext: + runAsNonRoot: true runAsUser: 999 runAsGroup: 999 fsGroup: 999 + seccompProfile: + type: RuntimeDefault securityContext: allowPrivilegeEscalation: false diff --git a/deploy/chart-gestion/traefik/Chart.lock b/deploy/chart-gestion/traefik/Chart.lock new file mode 100644 index 0000000..0d422d6 --- /dev/null +++ b/deploy/chart-gestion/traefik/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: traefik + repository: https://helm.traefik.io/traefik + version: 41.1.0 +digest: sha256:62635ee99f7e539f6a9206239003249a65f4a4cf0e5606fecc9578ed0cfa31ef +generated: "2026-07-31T12:07:20.158740949+02:00" diff --git a/deploy/chart-gestion/traefik/Chart.yaml b/deploy/chart-gestion/traefik/Chart.yaml index 4462e0b..2496046 100644 --- a/deploy/chart-gestion/traefik/Chart.yaml +++ b/deploy/chart-gestion/traefik/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: gestion-traefik -description: Traefik ingress + Coraza WAF (OWASP CRS) — gestion-commande +description: Traefik partagé multi-tenant + Coraza WAF (OWASP CRS) — routeur unique pour toutes les démos, installé une seule fois (voir deploy/chart-gestion/ingressroute pour le routage par démo) type: application version: 0.1.0 appVersion: "3.1.0" diff --git a/deploy/chart-gestion/traefik/charts/traefik-41.1.0.tgz b/deploy/chart-gestion/traefik/charts/traefik-41.1.0.tgz new file mode 100644 index 0000000..387a5a5 Binary files /dev/null and b/deploy/chart-gestion/traefik/charts/traefik-41.1.0.tgz differ diff --git a/deploy/chart-gestion/traefik/templates/_helpers.tpl b/deploy/chart-gestion/traefik/templates/_helpers.tpl index bf8166e..17feaff 100644 --- a/deploy/chart-gestion/traefik/templates/_helpers.tpl +++ b/deploy/chart-gestion/traefik/templates/_helpers.tpl @@ -4,9 +4,3 @@ app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} - -{{- define "gestion-traefik.certResolver" -}} -{{- if eq .Values.tls.mode "letsencrypt" -}} -letsencrypt -{{- end }} -{{- end }} diff --git a/deploy/chart-gestion/traefik/templates/ingressroute.yaml b/deploy/chart-gestion/traefik/templates/ingressroute.yaml deleted file mode 100644 index 883a606..0000000 --- a/deploy/chart-gestion/traefik/templates/ingressroute.yaml +++ /dev/null @@ -1,84 +0,0 @@ ---- -# HTTP → HTTPS redirect -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: gestion-http - namespace: {{ .Release.Namespace }} - labels: - {{- include "gestion-traefik.labels" . | nindent 4 }} -spec: - entryPoints: - - web - routes: - - match: Host(`{{ .Values.domain }}`) - kind: Rule - middlewares: - - name: redirect-https - services: - - name: noop@internal - kind: TraefikService ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: redirect-https - namespace: {{ .Release.Namespace }} -spec: - redirectScheme: - scheme: https - permanent: true ---- -# HTTPS — routes principales -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: gestion-https - namespace: {{ .Release.Namespace }} - labels: - {{- include "gestion-traefik.labels" . | nindent 4 }} -spec: - entryPoints: - - websecure - routes: - # API backend — WAF + headers + rate limit - - match: Host(`{{ .Values.domain }}`) && (PathPrefix(`/api/`) || PathPrefix(`/webhook/`)) - kind: Rule - priority: 20 - middlewares: - - name: waf-chain - services: - - name: {{ .Values.backend.serviceName }} - namespace: {{ .Values.servicesNamespace }} - port: {{ .Values.backend.servicePort }} - - # Fichiers uploadés — WAF désactivé dans les règles Coraza pour ce path - - match: Host(`{{ .Values.domain }}`) && PathPrefix(`/uploads/`) - kind: Rule - priority: 15 - middlewares: - - name: security-headers - services: - - name: {{ .Values.backend.serviceName }} - namespace: {{ .Values.servicesNamespace }} - port: {{ .Values.backend.servicePort }} - - # Frontend SPA — WAF + headers + rate limit - - match: Host(`{{ .Values.domain }}`) - kind: Rule - priority: 1 - middlewares: - - name: waf-chain - services: - - name: {{ .Values.frontend.serviceName }} - namespace: {{ .Values.servicesNamespace }} - port: {{ .Values.frontend.servicePort }} - - tls: - {{- if eq .Values.tls.mode "letsencrypt" }} - certResolver: letsencrypt - {{- else }} - secretName: {{ .Values.tls.secretName }} - {{- end }} - domains: - - main: {{ .Values.domain }} diff --git a/deploy/chart-gestion/traefik/templates/tlsstore.yaml b/deploy/chart-gestion/traefik/templates/tlsstore.yaml index aac3728..9e44190 100644 --- a/deploy/chart-gestion/traefik/templates/tlsstore.yaml +++ b/deploy/chart-gestion/traefik/templates/tlsstore.yaml @@ -1,3 +1,6 @@ +# Certificat par défaut pour TOUTES les IngressRoute (ce namespace et les +# namespaces de démo) qui ne précisent pas tls.secretName — c'est le wildcard +# émis par cert-manager (voir deploy/cert-manager/wildcard-certificate.yml). apiVersion: traefik.io/v1alpha1 kind: TLSStore metadata: @@ -6,11 +9,5 @@ metadata: labels: {{- include "gestion-traefik.labels" . | nindent 4 }} spec: - {{- if eq .Values.tls.mode "secret" }} defaultCertificate: secretName: {{ .Values.tls.secretName }} - {{- end }} - defaultGeneratedCert: - resolver: {{ include "gestion-traefik.certResolver" . | default "letsencrypt" }} - domain: - main: {{ .Values.domain }} diff --git a/deploy/chart-gestion/traefik/values.yaml b/deploy/chart-gestion/traefik/values.yaml index 7241ea2..725e153 100644 --- a/deploy/chart-gestion/traefik/values.yaml +++ b/deploy/chart-gestion/traefik/values.yaml @@ -1,26 +1,16 @@ # ============================================================= -# Paramètres spécifiques à ce chart (IngressRoutes, Middlewares) +# Traefik partagé multi-tenant : un seul déploiement pour toutes les démos. +# Le routage par démo (Host -> Service) est fourni par le chart +# deploy/chart-gestion/ingressroute, installé une fois par namespace de démo. # ============================================================= -# Namespace des services backend et frontend -servicesNamespace: gestion - -domain: example.com - +# TLS : certificat wildcard émis une fois par cert-manager +# (voir deploy/cert-manager/), référencé ici comme certificat par défaut +# du TLSStore. Toutes les IngressRoute (dans ce namespace ou les namespaces +# de démo) qui ne précisent pas de tls.secretName utilisent automatiquement +# ce certificat. tls: - # "letsencrypt" | "secret" - mode: letsencrypt - email: admin@example.com - # Si mode=secret : nom du Secret TLS Kubernetes - secretName: gestion-tls - -backend: - serviceName: gestion-backend - servicePort: 8080 - -frontend: - serviceName: gestion-frontend - servicePort: 80 + secretName: wildcard-demo-tls waf: paranoia: 2 @@ -37,27 +27,34 @@ rateLimit: # https://github.com/traefik/traefik-helm-chart # ============================================================= traefik: - # Expose les ports 80 et 443 + # 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, + # voir charts/traefik-41.1.0.tgz). ports: web: port: 8000 expose: default: true - redirectTo: - port: websecure + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true websecure: port: 8443 expose: default: true - tls: - enabled: true + http: + tls: + enabled: true # Logs - logs: - general: - level: INFO - access: - enabled: true + log: + level: INFO + accessLog: + enabled: true # Plugin Coraza WAF — Traefik télécharge le plugin au démarrage # (le cluster doit avoir accès à internet ou utiliser un plugin local) @@ -67,20 +64,18 @@ traefik: moduleName: "github.com/corazawaf/coraza-traefik" version: "v0.3.0" - # Cert resolver Let's Encrypt (ignoré si tls.mode=secret) - certResolvers: - letsencrypt: - email: admin@example.com - storage: /data/acme.json - httpChallenge: - entryPoint: web + # ACME interne désactivé : c'est cert-manager qui gère le cycle de vie du + # certificat wildcard (voir deploy/cert-manager/), Traefik ne fait que le + # consommer via le TLSStore (templates/tlsstore.yaml). - # ACME storage persistant + # ACME storage persistant — plus nécessaire sans resolver Traefik interne, + # conservé désactivé par défaut. persistence: - enabled: true - size: 128Mi + enabled: false - # Traefik lit les IngressRoutes dans tous les namespaces + # Traefik lit les IngressRoute/Middleware dans tous les namespaces de démo, + # et peut référencer les Middlewares partagés (waf-chain) définis ici depuis + # une IngressRoute d'un autre namespace. providers: kubernetesCRD: enabled: true diff --git a/deploy/cni/calico-install.yml b/deploy/cni/calico-install.yml new file mode 100644 index 0000000..9d5ad43 --- /dev/null +++ b/deploy/cni/calico-install.yml @@ -0,0 +1,46 @@ +# Installation de Calico (CNI qui applique réellement les NetworkPolicy) — +# nécessaire pour que deploy/chart-gestion/network-policy soit autre chose +# qu'une déclaration ignorée par le cluster. +# +# ⚠️ Si le cluster a déjà un CNI actif (flannel, etc.), installer Calico en +# remplacement est disruptif : tous les pods doivent être recréés pour +# obtenir une IP du nouveau CNI. À faire lors d'une fenêtre de maintenance, +# pas à chaud sur un cluster avec des démos clients actives. Si vous voulez +# juste ajouter l'enforcement de policy SANS remplacer le CNI existant, dites- +# le : Calico peut aussi tourner en mode "policy-only" au-dessus de flannel +# (Canal), c'est un manifeste différent de celui-ci. +# +# Étape 1 (à lancer manuellement, pas inclus ici : c'est un manifeste upstream +# de plusieurs milliers de lignes, à récupérer directement depuis le projet) : +# +# kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/tigera-operator.yaml +# +# Étape 2 : une fois l'operator up (kubectl get pods -n tigera-operator), +# appliquer ce fichier : +# +# kubectl apply -f deploy/cni/calico-install.yml +# +# Étape 3 : vérifier l'enforcement avec deploy/cni/network-policy-test.yml. + +apiVersion: operator.tigera.io/v1 +kind: Installation +metadata: + name: default +spec: + calicoNetwork: + ipPools: + # ⚠️ Ce CIDR doit correspondre au pod CIDR réellement utilisé par le + # cluster (ou être libre s'il s'agit d'une installation neuve). Ne pas + # appliquer tel quel sans vérifier qu'il ne chevauche pas l'existant. + - name: default-ipv4-ippool + cidr: 192.168.0.0/16 + encapsulation: VXLAN + natOutgoing: Enabled + nodeSelector: all() + +--- +apiVersion: operator.tigera.io/v1 +kind: APIServer +metadata: + name: default +spec: {} diff --git a/deploy/cni/network-policy-test.yml b/deploy/cni/network-policy-test.yml new file mode 100644 index 0000000..de4d4af --- /dev/null +++ b/deploy/cni/network-policy-test.yml @@ -0,0 +1,95 @@ +# Vérification que le CNI applique bien les NetworkPolicy avant de faire +# confiance à deploy/chart-gestion/network-policy pour l'isolation des démos. +# +# kubectl apply -f deploy/cni/network-policy-test.yml +# kubectl wait -n netpol-test --for=condition=ready pod -l app=target --timeout=60s +# +# # Doit réussir (client autorisé) : +# kubectl exec -n netpol-test allowed-client -- wget -qO- --timeout=2 target +# +# # Doit échouer / timeout (client non autorisé — c'est le résultat attendu) : +# kubectl exec -n netpol-test blocked-client -- wget -qO- --timeout=2 target +# +# Si "blocked-client" arrive quand même à joindre "target", le CNI n'applique +# PAS les NetworkPolicy : deploy/chart-gestion/network-policy ne protège rien. +# +# Nettoyage : kubectl delete namespace netpol-test + +apiVersion: v1 +kind: Namespace +metadata: + name: netpol-test + +--- +apiVersion: v1 +kind: Pod +metadata: + name: target + namespace: netpol-test + labels: + app: target +spec: + containers: + - name: nginx + image: nginx:1.27-alpine + ports: + - containerPort: 80 + +--- +apiVersion: v1 +kind: Service +metadata: + name: target + namespace: netpol-test +spec: + selector: + app: target + ports: + - port: 80 + targetPort: 80 + +--- +apiVersion: v1 +kind: Pod +metadata: + name: allowed-client + namespace: netpol-test + labels: + role: allowed +spec: + containers: + - name: busybox + image: busybox:1.37 + command: ["sh", "-c", "sleep 3600"] + +--- +apiVersion: v1 +kind: Pod +metadata: + name: blocked-client + namespace: netpol-test + labels: + role: blocked +spec: + containers: + - name: busybox + image: busybox:1.37 + command: ["sh", "-c", "sleep 3600"] + +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-only-labeled + namespace: netpol-test +spec: + podSelector: + matchLabels: + app: target + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + role: allowed diff --git a/deploy/daemonset-beszel.yml b/deploy/daemonset-beszel.yml new file mode 100644 index 0000000..85950d1 --- /dev/null +++ b/deploy/daemonset-beszel.yml @@ -0,0 +1,63 @@ +# Applique aussi beszel-namespace.yml et beszel-agent-secret.yml (avant celui-ci). +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: beszel-agent + namespace: monitoring + labels: + app: beszel-agent +spec: + selector: + matchLabels: + app: beszel-agent + updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + template: + metadata: + labels: + app: beszel-agent + spec: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + tolerations: + - effect: NoSchedule + key: node-role.kubernetes.io/master + operator: Exists + - effect: NoSchedule + key: node-role.kubernetes.io/control-plane + operator: Exists + containers: + - name: beszel-agent + image: henrygd/beszel-agent:latest + imagePullPolicy: Always + envFrom: + - secretRef: + name: beszel-agent-hub + env: + - name: LISTEN + value: "45876" + - name: SYSTEM_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + volumeMounts: + - name: data + mountPath: /var/lib/beszel-agent + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + volumes: + - name: data + hostPath: + path: /var/lib/beszel-agent + type: DirectoryOrCreate + # - name: docker-sock + # hostPath: + # path: /var/run/docker.sock + # type: Socket