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 lignekind:Certain(refus au noyau) ouPresumed(score HTTP)enforce: active le refus L3/L4 ; faux par defautmaxEntries: budget, 4 000 par defautshardSize: prefixes par objet, 10 000 par defautminPrefixLength: ecarte les adresses isolees quand la liste en est pleinerefreshInterval: 2 h par defaut, jamais moins vite que demandepodSelector: 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¶
- Lire la source, en refusant les reponses non 200.
- Parser, fusionner, borner.
- Publier les tranches, puis la politique.
- Supprimer les tranches au-dela du nombre voulu.
- 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
Updatenaif 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¶
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-adminimplicite. Le role est la liste exacte des groupes d'API que le code utilise ; - sondes :
/healthzet/readyzsur 8081, liveness et readiness cables ; - election de chef : un bail
exoledge-operator.edge.exoledge.iodans le namespace, donc plusieurs replicas possibles sans double reconciliation ; - redemarrage automatique : la racine du conteneur est en lecture seule,
/tmpest unemptyDir, 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'
EnvoyFilterd'ext_authzn'a pas ete valide sur un cluster. Le nom de grappeoutbound|8080||service.namespace.svc.cluster.localest le format Istio attendu, mais c'est le premier objet a verifier au premierapply. - 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
cidrGroupSelectorvient du schema 1.20.3, pas d'une execution. - Un
CiliumCIDRGroupne peut pas appartenir a unEdgeSite: 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 :
deleteetdeletecollectiondoivent 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 unEdgeSiteretire 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) avecappliedet 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 :
Reconcileda chaque nouvelle generation,ApplyFailedavec 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: truecree uneDestinationRuleavech2UpgradePolicy: UPGRADE. L'operateur ne possede pas le Service de l'application, il ne peut donc pas y poserappProtocol: 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.