Objectif 🎯
À la fin de la section précédente, nous sommes arrivés à un kube avec tous les composants critiques de base installés. Il est temps de rendre notre cluster accessible sous 2 points d’entrées :
- Privée pour les outils internes, uniquement accessible à travers le réseau Tailnet.
- Publique pour les services web à exposer sur internet, uniquement accessibles via le Load Balancer Hetzner et sécurisée via une solution WAF interne sans nécessairement dépendre d’une solution classique externe renforcée telle que Cloudflare.
Architecture
Comme nous utilisons Hcloud, nous allons utiliser le LoadBalancer natif de Hetzner pour exposer notre Ingress Controller. Grâce à l’intégration cloud opéré par HCCM installé lors de la section 2 de ce guide, nous allons pouvoir automatiser la création et le management des LB Hetzner physique directement au niveau des annotations du service qui servira de LoadBalancer. Le but est d’implémenter l’architecture classique suivante :
Quant aux outils internes, nous allons les exposer via un Ingress interne, accessible uniquement depuis le réseau Tailnet.
Il est donc essentiel d’avoir 2 services bien distincts, un pour l’Ingress public et un pour l’Ingress privé. Les règles d’accès et typologie réseau seront en effet très différentes entre les 2, l’un nécessitant une exposition publique et nécessitant des protections particulières et l’autre étant restreint au réseau interne.
Certificate Issuer
Avant d’attaquer les hostilités avec Traefik, il faut nous débarrasser de la problématique des certificats TLS. Le plus flexible est de générer des wildcards via challenge DNS-01. Du fait de l’utilisation des DNS Scaleway dans ce guide, nous installerons le webhook en charge d’implémenter ce challenge sur les DNS Scaleway. À adapter selon votre propre DNS parmi la myriade de choix entre les providers, ou au pire des cas, implémenter le. Enfin, nous définirons le ClusterIssuer à utiliser par défaut lors de la génération de notre futur certificat.
module "kube_ingress" { source = "../../modules/kube/ingress"
scw_dns_access_key = var.scw_dns_username scw_dns_secret_key = var.scw_dns_password
acme_email = "me@ohmytalos.io"}Explanation
En plus du traditionnel email pour l’ACME, nous aurons besoin d’avoir les variables scw_dns_access_key et scw_dns_secret_key correctement renseignées à générer depuis l’interface Scaleway. Ils sont indispensables pour l’accès en écriture à l’API DNS pour créer les entrées nécessaires à la résolution du challenge DNS-01.
variable "scw_dns_username" { type = string}
variable "scw_dns_password" { type = string sensitive = true}# ...
export TF_VAR_scw_dns_username=$(bw_field username scw_dns)export TF_VAR_scw_dns_password=$(bw_field password scw_dns)variable "scw_dns_access_key" { description = "The access key for the Scaleway DNS API" type = string}
variable "scw_dns_secret_key" { description = "The secret key for the Scaleway DNS API" type = string sensitive = true}resource "helm_release" "cert_manager_webhook_scaleway" { repository = "https://helm.scw.cloud" chart = "scaleway-certmanager-webhook" version = "0.4.1"
name = "scw" namespace = "cert-manager" max_history = 2
set = [ { name = "secret.accessKey" value = var.scw_dns_access_key } ]
set_sensitive = [ { name = "secret.secretKey" value = var.scw_dns_secret_key } ]}
resource "kubernetes_manifest" "cluster_issuer_letsencrypt_production" { manifest = { apiVersion = "cert-manager.io/v1" kind = "ClusterIssuer" metadata = { name = "letsencrypt-production" } spec = { acme = { email = var.acme_email privateKeySecretRef = { name = "letsencrypt-production" } server = "https://acme-v02.api.letsencrypt.org/directory" solvers = [ { dns01 = { webhook = { groupName = "acme.scaleway.com" solverName = "scaleway" } } } ] } } } depends_on = [helm_release.cert_manager_webhook_scaleway]}Traefik
module "kube_ingress" { // ...
crowdsec_bouncer_lapi_key = var.crowdsec_bouncer_lapi_key
traefik_http_basic_auth_username = var.traefik_internal_basic_auth_username traefik_http_basic_auth_password = var.traefik_internal_basic_auth_password
traefik_service_annotations = { for key, value in { name = "${local.cluster_name}-traefik" type = "lb11" location = "nbg1" use-private-ip = "true" private-ipv4 = "10.0.1.100" uses-proxyprotocol = "true" health-check-interval = "15s" health-check-timeout = "10s" health-check-retries = "3" } : "load-balancer.hetzner.cloud/${key}" => value }
common_name = "ohmytalos.io" dns_names = [ "ohmytalos.io", "*.ohmytalos.io", "*.dev.ohmytalos.io" ] internal_domain = local.internal_domain}Explanation
Bien que les services internes soient déjà prévus d’être protégés via le réseau Tailnet, il reste indispensable de leur rajouter un middleware de protection HTTP basic auth. Il n’est pas question de laisser l’accès au dashboard longhorn ouvert même sur du réseau interne. Nous rajoutons donc les variables traefik_http_basic_auth_username et traefik_http_basic_auth_password pour définir les identifiants d’accès.
Nous prévoyons également d’installer le plugin CrowdSec bouncer pour Traefik, qui nous permettra de protéger l’Ingress public contre les attaques web courantes. Un token crowdsec_bouncer_lapi_key sera nécessaire pour que le plugin puisse s’authentifier auprès de l’API locale de CrowdSec. Générer le via openssl rand -hex 10 et stocker le dans votre vault.
Nous aurons besoin également de définir les annotations du service de type LoadBalancer pour que le LB Hetzner soit créé avec les bonnes options, grâce au HCCM déployé précédemment. L’utilisation du paramètre uses-proxyprotocol est indispensable pour que Traefik puisse récupérer la bonne IP source du client, au lieu de l’IP du LB Hetzner, permettant le bon fonctionnement du futur WAF. Nous utiliserons le protocole TCP par défaut, et donc du TLS Passthrough au niveau du port 443, la responsabilité de la terminaison TLS étant déléguée à Traefik.
Enfin nous définissons les common_name et dns_names pour générer 2 certificats TLS wildcard via le ClusterIssuer Let’s Encrypt que nous avons défini préalablement, un pour l’interne et un pour l’externe.
// ...
variable "traefik_internal_basic_auth_username" { type = string}
variable "traefik_internal_basic_auth_password" { type = string sensitive = true}
variable "crowdsec_bouncer_lapi_key" { type = string sensitive = true}# ...
export TF_VAR_traefik_internal_basic_auth_username=$(bw_field username traefik_internal_basic_auth)export TF_VAR_traefik_internal_basic_auth_password=$(bw_field password traefik_internal_basic_auth)export TF_VAR_crowdsec_bouncer_lapi_key=$(bw_field password crowdsec_bouncer_lapi_key)// ...
variable "common_name" { description = "The main domain name to use for the certificate" type = string}
variable "dns_names" { description = "The list of DNS names to use for the certificate" type = list(string)}
variable "traefik_http_basic_auth_username" { description = "The username for the basic auth" sensitive = true}
variable "traefik_http_basic_auth_password" { description = "The password for the basic auth" sensitive = true}
variable "internal_domain" { description = "The internal domain name to use for the private network" type = string}
variable "traefik_service_annotations" { description = "The annotations to add to the traefik ingress" type = map(string) default = {}}
variable "crowdsec_bouncer_lapi_key" { description = "The API key for bouncer for the crowdsec local API" type = string sensitive = true}resource "kubernetes_namespace_v1" "traefik" { metadata { name = "traefik" }}
resource "kubernetes_manifest" "certificate_default_certificate" { manifest = { apiVersion = "cert-manager.io/v1" kind = "Certificate" metadata = { name = "default-certificate" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } spec = { commonName = var.common_name dnsNames = var.dns_names issuerRef = { kind = kubernetes_manifest.cluster_issuer_letsencrypt_production.manifest.kind name = kubernetes_manifest.cluster_issuer_letsencrypt_production.manifest.metadata.name } secretName = "tls-default-certificate" } }}
resource "kubernetes_secret_v1" "internal_basic_auth" { metadata { name = "internal-basic-auth" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } type = "kubernetes.io/basic-auth"
data = { username = var.traefik_http_basic_auth_username password = var.traefik_http_basic_auth_password }}
resource "kubernetes_manifest" "traefik_middleware_internal_basic_auth" { manifest = { apiVersion = "traefik.io/v1alpha1" kind = "Middleware" metadata = { name = "internal-basic-auth" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } spec = { basicAuth = { secret = kubernetes_secret_v1.internal_basic_auth.metadata[0].name } } }}
resource "kubernetes_manifest" "traefik_middleware_internal_ips" { manifest = { apiVersion = "traefik.io/v1alpha1" kind = "Middleware" metadata = { name = "internal-ips" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } spec = { ipWhiteList = { sourceRange = [ "127.0.0.1/32", "100.64.0.0/10", ] } } }}
resource "kubernetes_manifest" "traefik_middleware_crowdsec_bouncer" { manifest = { apiVersion = "traefik.io/v1alpha1" kind = "Middleware" metadata = { name = "crowdsec-bouncer" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } spec = { plugin = { bouncer = { enabled = true crowdsecMode = "appsec" crowdsecAppsecEnabled = true crowdsecAppsecHost = "crowdsec-appsec-service.crowdsec:7422" crowdsecLapiScheme = "http" crowdsecLapiHost = "crowdsec-service.crowdsec:8080" crowdsecLapiKey = var.crowdsec_bouncer_lapi_key crowdsecAppsecUnreachableBlock = false } } } }}
resource "kubernetes_manifest" "traefik_middleware_compress" { manifest = { apiVersion = "traefik.io/v1alpha1" kind = "Middleware" metadata = { name = "compress" namespace = kubernetes_namespace_v1.traefik.metadata[0].name } spec = { compress = {} } }}
resource "helm_release" "traefik" { repository = "https://traefik.github.io/charts" chart = "traefik" version = "39.0.0"
name = "traefik" namespace = kubernetes_namespace_v1.traefik.metadata[0].name max_history = 2
set = [ { name = "deployment.kind" value = "DaemonSet" }, { name = "providers.kubernetesCRD.allowCrossNamespace" value = "true" }, { name = "ingressRoute.dashboard.enabled" value = "true" }, { name = "ingressRoute.dashboard.matchRule" value = "Host(`traefik.${var.internal_domain}`)" }, { name = "ingressRoute.dashboard.middlewares[0].name" value = kubernetes_manifest.traefik_middleware_internal_basic_auth.manifest.metadata.name }, { name = "tlsStore.default.defaultCertificate.secretName" value = kubernetes_manifest.certificate_default_certificate.manifest.spec.secretName }, { name = "logs.general.level" value = "FATAL" }, { name = "logs.access.enabled" value = "true" }, { name = "logs.access.format" value = "json" }, { name = "experimental.plugins.bouncer.moduleName" value = "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" }, { name = "experimental.plugins.bouncer.version" value = "v1.5.0" }, { name = "metrics.prometheus.serviceMonitor.enabled" value = "true" }, { name = "tracing.addInternals" value = "true" }, { name = "tracing.otlp.enabled" value = "true" }, { name = "tracing.otlp.http.enabled" value = "true" }, { name = "tracing.otlp.http.endpoint" value = "http://alloy.telemetry:4318/v1/traces" }, { name = "ports.web.http.redirections.entryPoint.to" value = "websecure" }, { name = "ports.web.http.redirections.entryPoint.scheme" value = "https" }, { name = "ports.web.http.redirections.entryPoint.permanent" value = "true" }, { name = "ports.websecure.asDefault" value = "true" }, { name = "ports.websecure.transport.respondingTimeouts.readTimeout" value = "300s" }, { name = "ports.ssh.port" value = "2222" }, { name = "ports.ssh.exposedPort" value = "22" }, { name = "ports.ssh.expose.default" value = "true" }, { name = "ports.internal.port" value = "9443" }, { name = "ports.internal.exposedPort" value = "443" }, { name = "ports.internal.expose.internal" value = "true" }, { name = "ports.internal.http.tls.enabled" value = "true" }, { name = "service.type" value = length(var.traefik_service_annotations) == 0 ? "ClusterIP" : "LoadBalancer" }, { name = "service.additionalServices.internal.type" value = "ClusterIP" } ]
set_list = concat( [ { name = "ports.internal.http.middlewares" value = ["traefik-${kubernetes_manifest.traefik_middleware_internal_ips.manifest.metadata.name}@kubernetescrd"] }, { name = "ingressRoute.dashboard.entryPoints" value = ["internal"] }, { name = "ports.websecure.http.middlewares" value = [ "traefik-${kubernetes_manifest.traefik_middleware_crowdsec_bouncer.manifest.metadata.name}@kubernetescrd", "traefik-${kubernetes_manifest.traefik_middleware_compress.manifest.metadata.name}@kubernetescrd" ] } ], [ for entry_point in ["ssh", "web", "websecure", "internal"] : { name = "ports.${entry_point}.proxyProtocol.trustedIPs" value = ["127.0.0.1/32", "10.0.0.0/8"] } ], [ for entry_point in ["ssh", "web", "websecure", "internal"] : { name = "ports.${entry_point}.forwardedHeaders.trustedIPs" value = ["127.0.0.1/32", "10.0.0.0/8"] } ] )
values = [ yamlencode({ service = { annotations = var.traefik_service_annotations } }) ]}Explanation
Il y a énormément à dire sur cette configuration de Traefik qui couvre tous les points évoqués ci-dessus.
Tout d’abord, nous créons le certificat TLS wildcard par défaut du cluster qui sera généré via le ClusterIssuer Let’s Encrypt préalablement défini. Il sera stocké dans le secret tls-default-certificate et référencé dans la configuration de Traefik.
Nous définissons pas moins de 4 middlewares :
Endpoint public :
crowdsec-bouncer: le plugin CrowdSec pour Traefik, qui se connecte au service CrowdSec du namespacecrowdsecque l’on déploiera juste après. On ne bloque pas la requête tant que le service AppSec n’est pas démarré.compress: un middleware de compressionzstd,brotliougzipdes réponses HTTP et assets pour améliorer les performances.
Endpoint privé :
internal-basic-auth: un middleware de protection HTTP basic auth pour protéger l’accès aux services internes critiques (dont dashboard Traefik, Longhorn et Hubble).internal-ips: un middleware de whitelist IP pour forcer l’accès au port interne 9443 uniquement aux IPs du réseau Tailnet et localhost, empêchant tout risque d’exposition externe.
Nous activons tous les services d’observabilité :
- Les logs d’accès au format
json, qui sera parsé et analysé par le WAF. - Les métriques Prometheus.
- Le tracing OpenTelemetry, qui sera envoyé plus tard vers Alloy via le collector OTLP.
Autres points :
- Nous déployons Traefik en
DaemonSetpour qu’il soit présent sur tous les nœuds du pool worker. - Le dashboard est activé et protégé par le middleware
internal-basic-auth, match l’hôtetraefik.dev.ohmytalos.io, et est accessible exclusivement via le portinternal9443. - Redirection automatique de HTTP vers HTTPS.
- Entrypoint
websecurepar défaut. - Ajout du port
sshréservé pourgiteaplus tard. - Ajout du port
internalavec activation du TLS, protégé par le middlewareinternal-ips, empêchant tout risque d’exposition des services internes en cas de mauvaise configuration.
De très loin la configuration la plus complexe jusqu’ici, lancer la commande terraform apply devrait vous déployer un Traefik avec son certificat SSL.
Avant de continuer, assurez-vous que le certificat est bien généré et en status Ready via la commande cmctl status certificate default-certificate -n traefik. Vous pouvez vérifier les challenges en cours via la commande k get challenges -n traefik. Cette opération peut prendre plusieurs minutes.
Enfin, vérifier que le load balancer Hetzner est bien créé et que le service LoadBalancer de Traefik possède une IP publique attribuée via kgs -n traefik. Cela devrait afficher quelque chose comme suit avec les 2 services, un public et un privé :
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEtraefik LoadBalancer 10.43.142.148 10.0.1.100,2a01:4f8:1c1f:7ffa::1,91.98.5.26 22:30310/TCP,80:32093/TCP,443:32736/TCP 17dtraefik-internal ClusterIP 10.43.245.78 <none> 443/TCP 17dSi EXTERNAL-IP est en Pending, alors quelque chose coince au niveau de la création du load balancer physique.

Vérifier le status des 9 services (3x3) :

Vous pouvez dès à présent enregistrer les entrées DNS suivantes :
@ 3600 IN A <public_ipv4>@ 3600 IN AAAA <public_ipv6>dev 3600 IN A <private_ipv4_tailnet_control_plane_nbg1>dev 3600 IN A <private_ipv4_tailnet_control_plane_fsn1>dev 3600 IN A <private_ipv4_tailnet_control_plane_hel1>* 3600 IN CNAME ohmytalos.io.*.dev 3600 IN CNAME dev.ohmytalos.io.Après un certain temps de propagation, aller sur https://test.ohmytalos.io/ pour tomber sur la 404 classique de Traefik avec le certificat valide.
HAProxy
Voilà un bon gros morceau de fait. Il reste maintenant à accéder à nos services internes de manière sécurisée via Tailnet. Pour rappel, seuls les control planes sont branchés sur ce réseau privé, il est donc logique d’accéder à nos services au travers d’eux. L’idée est donc de mettre en place un HAProxy en mode TCP 443 (TLS Passthrough) sur chaque control plane, qui fera office de reverse proxy TCP pour router les connexions vers le service traefik-internal du cluster Kubernetes, la partie certificat *.dev.ohmytalos.io étant déjà réglé à l’étape précédente. Le schéma suivant récapitulatif :
resource "kubernetes_namespace_v1" "haproxy" { metadata { name = "haproxy" labels = { "pod-security.kubernetes.io/enforce" = "privileged" } }}
resource "helm_release" "haproxy" { repository = "https://haproxytech.github.io/helm-charts" chart = "haproxy" version = "1.27.0"
name = "haproxy" namespace = kubernetes_namespace_v1.haproxy.metadata[0].name max_history = 2
set = [ { name = "kind" value = "DaemonSet" }, { name = "daemonset.useHostNetwork" value = "true" }, { name = "daemonset.useHostPort" value = "true" }, { name = "dnsPolicy" value = "ClusterFirstWithHostNet" }, { name = "tolerations[0].key" value = "node-role.kubernetes.io/control-plane" }, { name = "tolerations[0].operator" value = "Exists" }, { name = "nodeSelector.node-role\\.kubernetes\\.io/control-plane" value = "" }, { name = "config" value = <<EOFglobal log stdout format raw local0 maxconn 1024
defaults log global timeout client 60s timeout connect 60s timeout server 60s
frontend traefik_in bind :443 default_backend traefik_backend
backend traefik_backend server traefik-internal traefik-internal.traefik:443 check send-proxy-v2EOF } ]}Explanation
Rien de bien particulier ici, nous déployons HAProxy en DaemonSet sur les control planes uniquement, avec le hostNetwork et hostPort activé pour binder le port 443 de chaque nœud. Le chart est configuré pour router toutes les connexions entrantes sur le port 443 vers le service traefik-internal du namespace traefik, en utilisant le mode send-proxy-v2 pour que Traefik puisse récupérer la bonne IP source du client et traverser le middleware de protection d’ips internes tailnet.
Déployer le tout avec terraform apply, et vous devriez être capable d’accéder au dashboard Traefik interne sur https://traefik.dev.ohmytalos.io/ via le réseau Tailnet, le tout protégé par le middleware HTTP basic auth. Assurez-vous d’avoir bien enregistré les IPs internes tailnet sur votre DNS pour tous les sous-domaines *.dev.

Vous devriez également pouvoir accéder aux dashboards de Hubble UI et Longhorn UI, respactivement sur hubble.dev.ohmytalos.io et longhorn.dev.ohmytalos.io via le réseau Tailnet.
CrowdSec
Il ne reste plus que notre WAF à mettre en place pour un truc pro. CrowdSec sera la solution privilégiée pour protéger l’Ingress public. Il est composé à la fois d’un véritable WAF AppSec qui bloque en temps réel la plupart des attaques courantes dont le top 10 OWASP, mais aussi d’un analyseur comportemental par extraction des données de logs.
Diagramme de séquence en cas de requête malveillante :
Et dans le cadre d’une requête légitime :
terraform { required_providers { hcloud = { source = "hetznercloud/hcloud" version = ">= 1.43.0" } }
// ...}
// ...
provider "hcloud" { token = var.hcloud_token}data "hcloud_servers" "workers" { with_selector = "type=worker"}
data "hcloud_servers" "control_planes" { with_selector = "type=controlplane"}
module "kube_ingress" { // ...
crowdsec_enroll_key = var.crowdsec_enroll_key crowdsec_enroll_instance_name = local.cluster_name
crowdsec_whitelist_ips = concat( [for s in data.hcloud_servers.control_planes.servers : s.ipv4_address], [for s in data.hcloud_servers.workers.servers : s.ipv4_address], [for s in data.hcloud_servers.control_planes.servers : s.ipv6_address], [for s in data.hcloud_servers.workers.servers : s.ipv6_address] )
crowdsec_whitelist_rule_ids = [ 911100, 920420, 920450, ]}Explanation
Vous pouvez récupérer votre clé d’enrôlement depuis l’interface web de CrowdSec Cloud.
Petite particularité ici, on whiteliste les IPs publiques de tous les nœuds du cluster (control planes et workers) pour des raisons évidentes. Pour cela, n’oubliez pas de déclarer le provider hcloud afin de récupérer les IPs depuis l’API Hetzner.
Vu que l’on activera le WAF crs en mode bloquant, il faut anticiper les faux positifs en prévoyant de whitelister facilement les règles modsecurity.
// ...variable "crowdsec_enroll_key" { type = string sensitive = true}# ...
export TF_VAR_crowdsec_bouncer_lapi_key=$(bw_field password crowdsec_bouncer_lapi_key)// ...
variable "crowdsec_enroll_key" { description = "The enroll key for the crowdsec agent" type = string sensitive = true}
variable "crowdsec_enroll_instance_name" { description = "The instance name to use for the crowdsec agent enrollment" type = string}
variable "crowdsec_whitelist_ips" { description = "The public IPs to whitelist in crowdsec" type = list(string) default = []}
variable "crowdsec_whitelist_rule_ids" { description = "The IDs of the crowdsec rules to whitelist" type = list(number) default = []}resource "kubernetes_namespace_v1" "crowdsec" { metadata { name = "crowdsec" labels = { "pod-security.kubernetes.io/enforce" = "privileged" } }}
resource "helm_release" "crowdsec" { repository = "https://crowdsecurity.github.io/helm-charts" chart = "crowdsec" version = "0.21.1"
name = "crowdsec" namespace = kubernetes_namespace_v1.crowdsec.metadata[0].name max_history = 2
set = [ { name = "container_runtime" value = "containerd" }, { name = "agent.acquisition[0].namespace" value = "traefik" }, { name = "agent.acquisition[0].podName" value = "traefik-*" }, { name = "agent.acquisition[0].program" value = "traefik" }, { name = "agent.acquisition[0].poll_without_inotify" value = "true" }, { name = "agent.env[0].name" value = "COLLECTIONS" }, { name = "agent.env[0].value" value = "crowdsecurity/traefik" }, { name = "agent.env[1].name" value = "POSTOVERFLOWS" }, { name = "agent.env[1].value" value = "crowdsecurity/seo-bots-whitelist" }, { name = "lapi.metrics.serviceMonitor.enabled" value = "true" }, { name = "agent.metrics.serviceMonitor.enabled" value = "true" }, { name = "lapi.persistentVolume.data.storageClassName" value = "longhorn-crypto" }, { name = "lapi.persistentVolume.config.storageClassName" value = "longhorn-crypto" }, { name = "lapi.env[0].name" value = "BOUNCER_KEY_traefik" }, { name = "lapi.env[0].value" value = var.crowdsec_bouncer_lapi_key }, { name = "lapi.env[1].name" value = "ENROLL_KEY" }, { name = "lapi.env[1].value" value = var.crowdsec_enroll_key }, { name = "lapi.env[2].name" value = "ENROLL_INSTANCE_NAME" }, { name = "lapi.env[2].value" value = var.crowdsec_enroll_instance_name }, { name = "config.parsers.s02-enrich.01-my-whitelist\\.yaml" value = yamlencode({ name = "my/whitelist" description = "Whitelist events from my IPs" whitelist = { reason = "My IPs" ip = var.crowdsec_whitelist_ips } }) }, { name = "appsec.enabled" value = "true" }, { name = "appsec.env[0].name" value = "COLLECTIONS" }, { name = "appsec.env[0].value" value = "crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-crs-inband" }, { name = "appsec.configs.my-whitelist-rules\\.yaml" value = yamlencode({ name = "my/whitelist-rules" on_load = [ { apply = [ for id in var.crowdsec_whitelist_rule_ids : "RemoveInBandRuleByID(${id})" ] } ] }) }, ]
values = [ yamlencode({ appsec = { acquisitions = [ { source = "appsec" listen_addr = "0.0.0.0:7422" path = "/" appsec_configs = [ "crowdsecurity/virtual-patching", "crowdsecurity/crs-inband", "my/whitelist-rules" ] labels = { type = "appsec" } } ] } }) ]}Explanation
L’important ici est d’activer l’acquisition des logs de Traefik via le container_runtime containerd, en ciblant le namespace traefik et les pods traefik-*.
On active également le module AppSec de CrowdSec, qui fera office de WAF, tout en lui exposant le service sur le port 7422 qui sera consommé par le plugin Traefik.
Pour la 1ère fois, nous avons l’occasion de tester les volumes longhorn chiffrés pour stocker les données de CrowdSec LAPI. Pour cela, nous allons utiliser le paramètre storageClassName configuré sur longhorn-crypto. Les volumes seront ainsi dynamiquement créés et provisionnés.
On oublie pas de configurer la whitelist des IPs publiques du cluster, ainsi que ceux des bots SEO via le postoverflows crowdsecurity/seo-bots-whitelist. Utiliser les règles modsecurity pour éviter les faux positifs, par l’utilisation de RemoveInBandRuleByID.
Comme d’habitude, un petit coup de terraform apply pour lancer le déploiement. Vérifier les logs des agents via kl -n crowdsec ds/crowdsec-agent, afin de vous assurer que les logs de Traefik sont bien ingérés. Côté longhorn, 2 nouveaux volumes devraient être créés pour CrowdSec.
Pour vérifier le bon fonctionnement du WAF, tester rapidement avec https://test.ohmytalos.io/.env. Vous devriez avoir un retour 403. Grâce à la collection CRS, les failles top 10 OWASP les plus communes telles que celles basées sur XSS et injection SQL sont également bloquées.
# XSS -> 403curl "https://test.ohmytalos.io/?<script>alert(1)</script>" -v
# SQL injection -> 403curl "https://test.ohmytalos.io/?username=1'%20or%20'1'%20=%20'1&password=1'%20or%20'1'%20=%20'1" -vConclusion
Voilà pour la partie Ingress, qui n’est pas si triviale que ça quand il s’agit d’avoir quelque chose un minimum sérieux. Vous avez désormais un Ingress public robuste, sécurisé et prêt à être pleinement monitoré, ainsi qu’un accès privé aux services internes via Tailnet. Le tout avec des certificats TLS valides et renouvelés automatiquement.
Pas mal non ? Il faut maintenant s’occuper des backups et de la mise en place des clusters de base de données pour nos futures applications, en nous appuyant sur les opérateurs installés lors de la section précédente. Suite à la prochaine section.