Aller au contenu

Operateur Exoledge

Un seul objet par site, un seul objet par liste, et l'operateur possede tout le reste : certificat, porte d'entree, route, filtre, decideur, et regles Cilium.

Le partage des roles

Deux couches, deux metiers.

Cilium coupe. Une politique ingressDeny avec cidrGroupRef fait jeter le paquet au noyau, avant Envoy : pas de page servie, pas de cycle de preuve de travail, pas de cout applicatif. C'est la bonne couche pour une adresse dont on est sur qu'elle attaque.

Le filtre negocie. Une preuve de travail ou un captcha ne se servent pas au niveau IP. Les listes de presomption (proxys ouverts, Tor, DNS dynamique) contiennent de vrais utilisateurs : elles passent par le decideur HTTP, qui propose une epreuve au lieu de couper.

Mettre toutes les listes dans Cilium ne marche pas, et pas pour une raison de gout : le plan de politique du noyau compte 65536 entrees par point de terminaison, partagees avec toutes les autres politiques. Une liste de proxys pese pres de trois millions d'adresses. Il n'y a pas de compromis possible.

Ce qui tient vraiment, mesures a l'appui

Contrainte Valeur Consequence
Taille d'un prefixe dans un CiliumCIDRGroup 17,4 octets 60 000 prefixes pour 1 Mio, 10 000 tiennent dans 170 Kio
Entrees du plan de politique Cilium 65 536 budget par defaut fixe a 4 000, marge gardee
firehol_level1 4 635 entrees tient presque dans le budget, la fusion le ramene
firehol_anonymous 2 906 430 entrees jamais dans le noyau, uniquement cote HTTP

La fusion compte autant que le budget : quatre /24 contigus coutent une entree au lieu de quatre. Sur une liste de scanners, c'est la difference entre tenir et saturer.

Les deux objets

BlocklistSource, cluster-scoped :

  • url : le fichier, une adresse ou un prefixe par ligne
  • kind : Certain (refus au noyau) ou Presumed (score HTTP)
  • enforce : active le refus L3/L4 ; faux par defaut
  • maxEntries : budget, 4 000 par defaut
  • shardSize : prefixes par objet, 10 000 par defaut
  • minPrefixLength : ecarte les adresses isolees quand la liste en est pleine
  • refreshInterval : 2 h par defaut, jamais moins vite que demande
  • podSelector : les points de terminaison proteges

EdgeSite, namespaced : domaines, amont, posture, TLS, captcha, decideur, filtre, et la liste des BlocklistSource a appliquer.

Deux canaux de publication

Le meme parseur, deux sorties, et c'est kind qui decide :

Certain + enforce  ->  CiliumCIDRGroup (tranches) + CiliumClusterwideNetworkPolicy
sinon              ->  ConfigMaps de tranches, lues par le decideur

Passer de l'un a l'autre nettoie l'autre canal : une liste qui devient « certaine » ne laisse pas derriere elle des ConfigMaps qui feraient doublon, et inversement.

La politique de refus

apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: deny-cve
spec:
  endpointSelector:
    matchLabels:
      istio: ingressgateway
  ingressDeny:
    - fromCIDRSet:
        - cidrGroupSelector:
            matchLabels:
              edge.exoledge.io/source: cve

Les tranches sont designees par etiquette, pas une par une. Deux consequences qui valent le detour : ajouter une tranche ne demande aucune modification de la politique, et une liste qui grandit ne connait pas de fenetre sans protection.

Le champ s'appelle bien cidrGroupRef (ou cidrGroupSelector) dans fromCIDRSet, pas fromCIDRGroupRef au premier niveau : c'est le schema du CRD Cilium 1.20.3 qui fait foi, et il decrit explicitement ce cas pour IngressDeny/EgressDeny.

La sante d'une origine

L'operateur ne sonde pas les origines lui-meme. Il lit la readiness des EndpointSlice du Service declare dans spec.upstream : c'est Kubernetes qui sait si un pod repond, avec la sonde de l'application, son delai et son seuil.

Ajouter une sonde HTTP maison par-dessus creerait une seconde source de verite, et deux avis divergents sur la meme origine. Le seul cas qu'elle couvrirait, une origine hors du cluster, n'existe pas ici : spec.upstream est une reference de Service.

Ce que l'operateur fait de cette lecture : status.origins[].ready, un motif lisible quand rien n'est pret, et le poids zero quand on veut retirer une origine sans la supprimer.

La reconciliation

  1. Lire la source, en refusant les reponses non 200.
  2. Parser, fusionner, borner.
  3. Publier les tranches, puis la politique.
  4. Supprimer les tranches au-dela du nombre voulu.
  5. Ecrire l'etat : entrees, tranches, empreinte, date, conditions.

Trois details qui evitent des incidents :

  • Le statut des objets d'autrui est preserve. Cilium ecrit dans le statut de ses politiques ; un Update naif l'efface.
  • Les etiquettes posees par un tiers sont fusionnees, pas remplacees.
  • Un echec de lecture reessaie en 5 minutes, pas dans 2 heures : une panne reseau passagere ne doit pas laisser le site sans protection jusqu'au cycle suivant.

Ce que l'operateur possede

Objet Role
Certificate certificat du site via cert-manager
Gateway porte d'entree HTTPS, Gateway API
HTTPRoute domaine vers amont
ConfigMap politique du site lue par le decideur
Deployment + Service decideur, deux replicas, lecture seule
EnvoyFilter branchement ext_authz, permissif en cas de panne
WasmPlugin filtre de preuve de travail, artefact OCI
CiliumCIDRGroup + CCNP refus L3/L4 des listes certaines

Schema

schema de l'implementation

                      listes amont (URL, une adresse par ligne)
                                    |
                                    v
                        +-----------------------+
                        |   BlocklistSource     |   cluster-scoped
                        |  kind, enforce, budget |
                        +-----------------------+
                                    |
                     lire, parser, fusionner, borner
                                    |
                   +----------------+----------------+
                   |                                 |
            Certain + enforce                  sinon (Presumed)
                   |                                 |
                   v                                 v
        CiliumCIDRGroup x N                 ConfigMap x N
        (tranches de 10 000)                (tranches de 5 000)
                   |                                 |
                   v                                 v
     CiliumClusterwideNetworkPolicy          decideur HTTP
     ingressDeny + cidrGroupSelector        /v1/verdict
                   |                                 |
                   v                                 |
        paquet jete au noyau                         |
        (avant Envoy, cout nul)                      |
                                                     v
                                            +------------------+
                                            |  Valkey (etat)   |
                                            +------------------+

   EdgeSite (namespaced), un par domaine
     -> Certificate        (cert-manager)
     -> Gateway            (Gateway API, TLS termine ici)
     -> HTTPRoute          (domaine vers amont)
     -> ConfigMap          (posture, captcha, amont)
     -> Deployment+Service (decideur, 2 replicas, lecture seule)
     -> EnvoyFilter        (ext_authz, permissif si panne)
     -> WasmPlugin         (filtre de preuve de travail, AUTHN)

Chemin d'une requete :

client
  |
  v
[Cilium] ingressDeny sur l'adresse source ?
  |                                   |
  | oui                               | non
  v                                   v
paquet jete                    [Gateway] TLS termine
                                      |
                                      v
                              [ext_authz] verdict
                          pass | captcha | pow | drop
                                      |
                                      v
                              [filtre wasm] epreuve
                                      |
                                      v
                                   amont

Le deploiement dans le cluster

L'operateur a sa couche : tofu/operator. Elle pose le namespace, les deux CRD telles que les genere controller-gen (lues depuis le depot, pas recopiees), le ClusterRole genere par controller-gen, son binding, le compte de service et le Deployment.

Ce qu'un binaire lance a la main n'avait pas et qui change tout :

  • identite de service : plus de kubeconfig sur une machine, plus de cluster-admin implicite. Le role est la liste exacte des groupes d'API que le code utilise ;
  • sondes : /healthz et /readyz sur 8081, liveness et readiness cables ;
  • election de chef : un bail exoledge-operator.edge.exoledge.io dans le namespace, donc plusieurs replicas possibles sans double reconciliation ;
  • redemarrage automatique : la racine du conteneur est en lecture seule, /tmp est un emptyDir, et le pod revient seul apres un eviction.

L'image est construite sans demon Docker, avec ko, qui compile le binaire et pousse l'image directement dans le registre Scaleway :

KO_DOCKER_REPO=rg.fr-par.scw.cloud/exoledge/operator \
  ko build --bare --tags 0.1.1 --platform=linux/amd64 .

Le namespace du registre est public, donc le noeud n'a aucun secret de tirage.

Premier defaut trouve en le deployant : un objet possede supprime a la main ne revenait pas. Le controleur applique ses huit objets a chaque reconciliation, mais ne regarde pas ceux qu'il possede. Une route effacee restait effacee jusqu'au prochain changement du site ou au redemarrage. La reconciliation demande desormais une reprise toutes les cinq minutes. Un watch sur les objets poses serait plus juste, il demande d'enregistrer chaque type non structure dans le schema.

Limites assumees

  • L'EnvoyFilter d'ext_authz n'a pas ete valide sur un cluster. Le nom de grappe outbound|8080||service.namespace.svc.cluster.local est le format Istio attendu, mais c'est le premier objet a verifier au premier apply.
  • Le namespace des ConfigMaps est code en dur (exoledge-system) : a passer en option avant tout usage reel.
  • Le decideur ne recharge pas ses listes a chaud. Les ConfigMaps sont montees ; il faut soit un watch, soit un redemarrage declenche par l'operateur sur changement d'empreinte.
  • Aucun essai contre un vrai Cilium. La semantique de cidrGroupSelector vient du schema 1.20.3, pas d'une execution.
  • Un CiliumCIDRGroup ne peut pas appartenir a un EdgeSite : il est cluster-scoped et le site est namespaced. D'ou la separation des deux objets, et le nettoyage par etiquette plutot que par ownerReference.

Le bloc de pilotage spec.security

Chaque interrupteur de la console correspond a un objet Kubernetes. L'operateur cree l'objet quand la fonction est active, et le retire quand elle est eteinte : sans ce retrait, une console qui affiche "desactive" laisserait le filtre actif sur la passerelle, le pire des etats.

spec:
  security:
    waf: true             # WasmPlugin Coraza + CRS
    challenge: true       # WasmPlugin preuve de travail
    sensitiveFiles: true  # AuthorizationPolicy DENY sur 93 suffixes
    blocklists: [proxies, cve_2024_3400]   # listes montees dans le decideur
    allowlists: [payment, crawlers]

Un champ absent garde le defaut du produit, qui est actif : un manifeste incomplet ne doit pas eteindre une protection par omission. Les defauts sont decrits dans SecuritySpec (api/v1alpha1/edgesite_types.go) et verifies par TestSecurityDefaults.

La suppression se fait par nom, pas par type : deux plugins partagent le meme type (WasmPlugin) et eteindre le challenge ne doit pas emporter le WAF.

Ce que la mise au point a appris

  • Un ServiceAccount qui sait creer ne sait pas forcement supprimer : delete et deletecollection doivent figurer dans les marqueurs RBAC, sinon l'echec est silencieux.
  • Un objet ajoute a la liste de deux facons (une fois inconditionnellement, une fois sous condition) est recree aussitot qu'on le supprime.
  • Une reconciliation declenchee par une suppression peut relire un cache en retard et ressusciter l'objet : l'etat se lit a l'API, pas dans le cache.
  • Le message de statut ne doit compter que ce qui a ete reellement applique, et l'echec d'application doit interrompre la boucle avec une condition ApplyFailed.

Le cycle de vie complet

  • Finaliseur edge.exoledge.io/finalizer : supprimer un EdgeSite retire d'abord ses objets (politiques, plugins, route, certificat, deploiement, service, configmaps), puis l'objet disparait. Sans lui, un site supprime laissait ses filtres actifs sur la passerelle.
  • status.features[] : une entree par fonction (waf, challenge, sensitiveFiles, blocklists, grpc) avec applied et la raison de l'ecart. C'est la source de verite pour la console.
  • Detection de derive : avant d'ecrire, l'operateur compare le vivant au desire. Sans ce test, chaque reconciliation reecrivait huit objets toutes les cinq minutes et masquait les vrais changements.
  • Evenements Kubernetes : Reconciled a chaque nouvelle generation, ApplyFailed avec le nom de l'objet fautif.
  • Validation a l'admission : domaines en minuscules, sans schema ni chemin (CEL), port entre 1 et 65535. Un manifeste malforme est refuse avant d'entrer dans la boucle.
  • gRPC : spec.grpc: true cree une DestinationRule avec h2UpgradePolicy: UPGRADE. L'operateur ne possede pas le Service de l'application, il ne peut donc pas y poser appProtocol: h2c ; la regle de destination suffit a negocier HTTP/2 clair.

Le pont avec la console

La console ne parle pas a l'API Kubernetes : elle produit le manifeste. Chaque domaine expose /domains/manifest?host=..., qui rend l'EdgeSite correspondant, avec le bloc security deduit des interrupteurs coches. Le manifeste est valide cote serveur (kubectl apply --dry-run=server) avant d'etre remis au client.

C'est le decoupage qui tient : la console decide, l'operateur applique, Git fait foi.

Un site qui rejoint un bord partage

Un EdgeSite peut creer sa propre passerelle, ou en rejoindre une avec spec.gatewayRef. Les deux modes ne produisent pas les memes objets.

Quand le site possede sa passerelle, il porte tout : certificat, decideur, configuration, filtre d'autorisation, WAF, defi. C'est le mode autonome, un client, un bord.

Quand il rejoint une passerelle existante, il n'apporte que sa route. Le certificat vient de l'ecouteur, le WAF, le defi, le decideur et les listes sont portes une seule fois par le bord, pour tous les sites. Le statut le dit : status.shared vaut vrai, et les fonctions concernees portent la raison « porte par le bord partage ».

Pourquoi cette distinction : le WAF et le filtre d'autorisation sont des filtres d'ecouteur, pas de route. Deux sites qui empileraient chacun les leurs sur la meme passerelle filtreraient deux fois le meme trafic. Le mode partage n'est donc pas un raccourci, c'est la seule forme correcte quand plusieurs domaines partagent une adresse.

Consequence assumee : dans ce mode, spec.security ne regle rien site par site. La console refuse la bascule en 409 avec le motif, plutot que d'ecrire une specification sans effet.

Routes par chemin

spec.routes remplace la regle unique quand un nom sert plusieurs destins : la racine pour la documentation, /app pour l'application. Chaque entree porte un prefixe et, si besoin, son propre amont. Prefixe le plus long gagne, l'ordre d'ecriture n'a pas d'importance.

Sans spec.routes, une seule regle existe : la racine, vers spec.upstream.

Plusieurs origines par regle

Une regle de chemin peut repartir son trafic sur plusieurs services :

routes:
  - prefix: /app
    upstreams:
      - name: demo
        port: 80
        weight: 300
      - name: demo-next
        port: 80
        weight: 100

Le poids est relatif : ici 75 pour cent vers demo, 25 pour cent vers demo-next. L'operateur en fait des backendRefs ponderes dans la route, et Envoy applique la repartition.

Le poids zero ne supprime rien : l'origine reste declaree, elle ne recoit plus rien. C'est le drainage, et c'est ce qu'il faut faire avant de retirer une origine pour ne pas couper les requetes en cours.

status.origins publie, origine par origine, le poids, le nombre de points de terminaison prets et la raison quand elle ne recoit rien. Une origine declaree n'est pas une origine saine : la console lit ce statut, elle ne suppose pas.