Aller au contenu

Overwatch

Console de controle du bord : ce qui passe, ce qui est bloque, par qui.

Le trajet d'une requete jusqu'a la console

client
  |
  v
envoy (passerelle)  -- ecrit une ligne JSON par requete sur stdout
  |
  v
noeud : /var/log/containers/*.log
  |
  v
fluent-bit (DaemonSet)  -- garde la passerelle, pousse en HTTP
  |
  v
overwatch  -- POST /api/ingest/v1, jeton porteur
  |
  v
PostgreSQL (CloudNativePG)  -- une ligne par requete, par organisation

Chaque etage a une raison d'etre la :

  • Envoy ecrit sur stdout parce qu'un flux fichier survit a la rotation et ne demande aucun recepteur a la console.
  • Le collecteur est un DaemonSet parce que les journaux sont sur le disque du noeud, pas dans une API.
  • L'ingestion est un POST HTTP parce que le premier design (recepteur gRPC du protocole ALS d'Envoy) ajoutait un format binaire et un serveur de plus pour un gain nul a cette echelle.
  • La base est un cluster CloudNativePG parce que sauvegarde, montee de version et restauration sont deja ecrites la.

Comptes, organisations, droits

Trois niveaux, et rien de plus :

  • Organisation : un client, ou un environnement. Il porte un ou plusieurs noms d'hote, et toutes ses lignes de journal lui sont rattachees.
  • Compte : un courriel, un mot de passe (argon2), un role dans son organisation. proprietaire et administrateur creent des comptes, lecteur regarde.
  • Administrateur de plateforme : cree les organisations et leurs jetons d'ingestion. C'est le seul role qui voit plusieurs organisations.

Le cloisonnement tient a une regle unique : toute requete SQL de lecture porte le organization_id de la session. Il n'y a pas de parametre de requete capable de designer un autre organisation, donc pas de controle d'acces a oublier. La liste des organisations est la seule page hors de ce modele, et elle verifie explicitement le drapeau d'administration.

Ingestion

POST /api/ingest/v1, en-tete Authorization: Bearer <jeton>, corps JSON : un objet, un tableau d'objets, ou un flux une ligne par objet. Les champs lus sont ceux qu'Envoy ecrit : authority, method, path, response_code, response_flags, bytes_sent, duration, user_agent, x_forwarded_for, upstream_host, request_id, start_time.

Deux sortes de jetons :

  • jeton de organisation : toutes les entrees vont chez lui, sans exception ;
  • jeton de plateforme (sans organisation) : les entrees sont aiguillees par nom d'hote, ce qui permet a un seul collecteur de servir plusieurs clients. Un nom inconnu tombe dans l'organisation par defaut plutot que d'etre perdu.

Les doublons sont ecartes par un index unique sur (organization_id, request_id) : un collecteur qui redemarre relit ses fichiers depuis le debut.

Ce que la console montre

  • Tableau de bord : volume, part bloquee, latence moyenne et p95, erreurs serveur, agents, series temporelles, adresses et chemins les plus actifs, codes de reponse, noms d'hote, marqueurs Envoy.
  • Journaux : filtres par hote, adresse, chemin, classe de code, blocage ; pagination.
  • Comptes et, pour la plateforme, organisations.

Un blocage est une reponse 4xx ou 5xx. C'est volontairement grossier : le journal d'acces d'Envoy ne dit pas quelle regle a bloque. Une attribution par regle demanderait que le filtre WAF pose un en-tete de reponse, et que le format de journal le transporte. C'est la prochaine etape utile, pas un oubli.

Exploitation

# mot de passe d'administration genere au premier apply
cd tofu/overwatch && tofu output -raw admin_password

# jeton d'ingestion, si besoin de brancher un autre collecteur
tofu output -raw ingest_token

Le mot de passe et le jeton sont dans le state. Le state contient deja l'etat complet du cluster : il se protege comme un secret, il ne se commite pas.

Limites assumees :

  • Un seul noeud, un seul replica de base : pas de bascule possible, et le volume est local, donc lie au noeud.
  • Pas de rotation automatique des jetons ni des mots de passe : ils se regenerent en changeant le secret, ce qui redeploie la console.
  • Purge a 30 jours par tache interne, sans archivage prealable.
  • Pas de CSRF token : les formulaires sont en SameSite=Lax, ce qui bloque les envois depuis un autre site, mais ne remplace pas un jeton. A faire avant d'exposer la console a plusieurs organisations.

Le temps reel

Trois mecanismes, et une lecon.

Les journaux sont un flux, pas une page. La page affiche les dernieres lignes, puis une interrogation courte (/logs/feed?since=<id>) toutes les 1,5 s ajoute celles qui manquent en tete du tableau. Le curseur est l'identifiant de la derniere ligne vue : rien ne peut etre perdu ni compte deux fois. La requete porte Cache-Control: no-store, sans quoi un intermediaire rendrait le flux muet. L'interrogation s'arrete quand l'onglet est cache et reprend immediatement quand il redevient visible.

Les agregats du tableau de bord sont rafraichis toutes les 3 s par /dashboard/stats, qui renvoie des valeurs deja mises en forme : la page et la mise a jour ne peuvent pas afficher deux notations differentes. Le calcul est fait une seule fois, dans une fonction partagee entre l'affichage initial et le rafraichissement ; deux copies auraient diverge. La page peut servir un resultat en cache quinze secondes, le rafraichissement jamais.

La lecon, payee cher. Le collecteur envoie plusieurs enregistrements par requete. Ils peuvent arriver colles les uns aux autres, sans saut de ligne entre eux. Un decoupage ligne par ligne ne lisait alors que le premier, et les autres disparaissaient sans un mot : la console affichait un trafic clairsemé et une latence fausse. Le corps se lit desormais comme un flux de valeurs JSON (Deserializer::into_iter), qui accepte aussi bien les objets concatenes que les lignes separees. Le compteur rejected de la reponse d'ingestion rend visible ce qui n'a pas ete lu.

Regle qui en decoule : un collecteur qui perd des lignes en silence est pire qu'un collecteur en panne. Toute perte doit etre comptee et exposee.

La console nouvelle generation

Les premieres pages etaient rendues par le binaire, en HTML colle. Elles ont fait leur travail : prouver la chaine. Elles ne tiennent pas la comparaison avec la maquette fournie (huit ecrans, quatre etapes d'initialisation), qui demande un etat client reel : filtres, flux en direct, tiroir de details, bascules optimistes.

La console est desormais une application React, servie sous /console/.

Pile : React 19, Vite 6, TypeScript strict, Tailwind 4 (les jetons de la maquette sont declares une seule fois dans src/styles.css), Radix pour le tiroir accessible, TanStack Query pour le cache et le rafraichissement, uPlot pour la courbe. Aucune ressource distante : ni police, ni script, ni image externe. La console fonctionne sur un reseau ferme.

Embarquement : overwatch/src/ui.rs embarque ui/dist par include_bytes!. Le binaire porte le bundle, donc l'image n'a besoin ni de Node ni de serveur statique, et il n'existe qu'un seul artefact a deployer. Les noms de fichiers sont fixes (assets/app.js, assets/index.css) pour que l'embarquement soit possible sans script de construction.

/console rend la page, /console/assets/* rend les fichiers, toute autre profondeur rend la page : c'est ce qui permet de recharger /console/logs sans repasser par la racine.

Langue : la console parle anglais, sans exception. Le produit vise le marche europeen, et une interface a moitie traduite se lit comme un prototype. Les nombres, les dates et les durees suivent en-GB (voir formatNumber, formatMs, relativeTime et clockTime dans components/ui.tsx) : une seule locale, pas de detection de navigateur, pour que deux operateurs regardent le meme ecran. Les chemins suivent la meme regle (/console/logs, /console/security, /console/lists, /console/accounts, /console/load).

Fonctions non abouties : une fonction qui n'a pas d'effet mesurable reste visible, mais grisee, avec le badge Coming soon et une infobulle qui dit pourquoi. On ne la supprime pas : un client qui ne voit pas une fonction croit qu'elle n'existe pas, un client qui la voit grisee sait qu'elle arrive. Le composant ComingSoon de components/ui.tsx porte ce badge, et le catalogue features.rs reste la source de verite (applied: false). Aujourd'hui cela concerne une seule entree, « Known attackers », dont aucun flux n'alimente encore la liste.

Construction : make ui-build (npm ci puis build), appele par make overwatch-build. ui/dist est commite : sans cela, un binaire ne peut pas etre recompile a partir d'un depot frais, alors qu'il embarque le bundle.

Ecrans, tous alimentes par l'API reelle (plus aucun ecran d'attente) :

  • Dashboard : cinq indicateurs, courbe des verdicts par tranche, adresses et chemins les plus actifs, codes de reponse, repartition par endpoint, etat reel du bord, plus la part des fonctions de securite appliquees.
  • Logs : flux en direct par SSE, filtres par verdict et par endpoint, recherche libre, export TSV, tiroir Why this verdict? qui montre les faits journalises (adresse, agent, JA4, JA4H, drapeaux, origine, empreinte). Une valeur absente est affichee « not observed », jamais zero.
  • Security : mode du WAF, familles de regles par domaine, niveau de paranoia, seuil d'anomalie, exclusions, presets applicatifs.
  • Lists : blocklists et allowlists, entrees et sources, avec la date de derniere synchronisation.
  • Anti-bot : defi, laissez-passer, mode sous pression et son reglage.
  • Load balancing : origines, poids, sante reelle, ajout et retrait.
  • Endpoints : domaines servis, origine, emetteur TLS, posture.
  • Administration et Accounts : organisations, comptes, roles, journal d'administration.

Lecon d'exploitation. Le moteur de detection a ferme l'adresse de test pendant la mise au point : trois requetes curl sans laissez-passer suffisent, et c'est le comportement voulu. Un navigateur resout le defi et passe, curl non. Pour verifier la console en ligne de commande, il faut donc soit resoudre le defi, soit lever la fermeture (DEL ban:<ip> dans Valkey), soit travailler depuis le navigateur. La console elle-meme passe par le meme bord : c'est une protection, pas une exception.

Un nom d'hote appartient a une seule organisation

Un endpoint declare appartient a une organisation, et l'aiguillage des journaux s'appuie sur organizations.hosts. Deux consequences, verifiees sur le cas overwatch.guillaume.ink :

  • le nom d'hote est retire de l'ancienne organisation quand il arrive dans la nouvelle. Sans cela, un collecteur muni d'un jeton de plateforme trouve deux proprietaires possibles et en choisit un au hasard ;
  • les lignes d'acces deja ecrites ne bougent pas. Une bascule se lit donc dans les journaux : les lignes d'avant restent chez l'ancien, celles d'apres arrivent chez le nouveau, et la frontiere est horaire.

L'entree dans le registre ne configure pas le bord : la passerelle, la route et le certificat restent declares par OpenTofu. Le registre dit a qui est l'endpoint et qui voit son trafic ; il ne le met pas en service.

Les ecrans ne se rechargent pas. Le routage est cote navigateur : un clic pousse l'adresse dans l'historique et React change d'ecran, sans aller-retour reseau. Trois consequences pratiques :

  • le flux en direct et le cache de requetes survivent au changement d'ecran ;
  • le bouton retour du navigateur fonctionne, et une adresse collee dans la barre arrive au bon ecran (le binaire rend la page pour toute profondeur) ;
  • le passage coute une transition de 180 ms, coupee si le systeme demande moins d'animation.

Verifie dans un navigateur : un marqueur pose sur window survit a un clic de navigation, l'adresse change, l'historique grandit d'une entree.

Les sites appartiennent a l'operateur

Les routes des sites ne sont plus des manifestes OpenTofu. Chaque domaine servi est un EdgeSite, et c'est l'operateur qui cree sa route, la maintient et rapporte son etat. OpenTofu garde la passerelle, les ecouteurs, les certificats et la pile de securite partagee.

Ce que ca change pour la console : elle lit les sites par l'API Kubernetes, pas par le registre. Un domaine peut exister dans le registre sans etre un site (il est declare mais pas encore servi), et un site peut exister sans domaine dans le registre. Les deux vues sont affichees separement, et la console ne comble jamais l'ecart par une supposition.

Journaux : cent lignes par page

La lecture est paginee par curseur, cent lignes par page, avec une navigation avant et arriere. La premiere page reste en direct (flux SSE) ; des qu'on avance, la page est figee, pour que la ligne qu'on lit ne bouge pas sous le curseur.

Les colonnes Adresse, Agent, Drapeaux, Octets et la duree viennent des faits journalises par le bord, pas d'une reconstitution. Un champ absent s'affiche « not observed » ; un drapeau vide s'affiche « none », ce qui veut dire qu'Envoy n'a rien signale, et non que la donnee manque.

Ecrans

Tous les ecrans de la maquette sont portes, et tous sont en anglais : le nom entre parentheses est celui que l'operateur lit dans la barre de navigation.

  • Securite (Security) : politique WAF par domaine (mode, familles, exceptions), appliquee sans redemarrage, avec l'audit des dernieres decisions. Le niveau de paranoïa et le seuil d'anomalie sont communs a la passerelle et affiches en lecture seule. Les listes de reputation y sont affichees avec leur nombre de plages et leur source ; une liste encore alimentee par un jeu d'exemple est grisee et marquee Coming soon.
  • Anti-bot (Anti-bot) : posture et interrupteur de defi par domaine, compteurs du bord sur 24 h, agents les plus vus, et la pression par site (/api/v1/pressure) : le niveau mesure et la possibilite de forcer attacked pour trente minutes, ou de revenir en automatique. Les seuils numeriques (110 %, 200 %, taux de resolution) ne sont pas encore exposes par la console : le panneau qui les decrit est grise, jamais rempli de valeurs inventees.
  • Repartition de charge (Load balancing) : origines declarees par les sites, trafic accepte par domaine, p95 et 5xx du bord. La sante vient de l'orchestrateur : une origine qui ne rapporte plus d'endpoints prets ne recoit plus rien. Une carte Cache y montre les moteurs poses, leur memoire, leur duree de vie, le taux de succes constate et l'empreinte de la politique en vigueur. Ces chiffres viennent du moteur, lus par l'operateur : la console ne les recalcule pas.
  • Administration (Administration) : organisation, jetons de collecte (creation, revocation), journal d'audit.
  • Comptes (Accounts) : liste, creation, changement de role, suppression, bornes a l'organisation de la session.

La navigation garde Administration et Accounts visibles : le bloc du bas est colle au bas de la barre, et la barre defile si l'ecran est court.

Le mode de la plateforme

Le selecteur Observation / Filtering de la barre superieure n'est pas un reglage local : il ecrit platform:mode dans le decideur, puis repousse la politique WAF, qui passe en detection. Rien n'est stocke dans le navigateur.

Pas d'interrupteur sur les endpoints

La page Endpoints decrit chaque domaine et ses fonctions, sans interrupteur. Le reglage se fait la ou il a un sens : WAF et familles dans Securite, defi dans Anti-bot, entrees manuelles dans Securite. Deux endroits pour regler la meme chose finissent par se contredire.

Mise en page

La barre de navigation est un rail colle en haut, haut comme l'ecran : sur une page longue, elle ne s'arrete plus au milieu du contenu. Sous 1024 px, elle devient un tiroir ouvert par le bouton de la barre superieure, et se referme seule au changement d'ecran.

Les tableaux defilent horizontalement dans leur carte. Les colonnes les moins utiles disparaissent d'abord sur petit ecran : agent et drapeaux sur les journaux, mais l'adresse, le statut et le verdict restent toujours visibles.

Repartition de charge

L'ecran ecrit spec.routes[].upstreams sur l'EdgeSite et rien d'autre : l'operateur reste seul a decider ce que devient cette liste.

  • ajouter une origine : un service du meme namespace, un port, un poids
  • mettre en maintenance : poids zero, l'origine reste declaree et ne recoit rien
  • retirer : la ligne disparait, le service n'est plus dans la route

Les points animes le long de chaque raccord suivent la part reelle de l'origine, pas une animation decorative. Une origine qui ne recoit rien n'a aucun point.

Point d'entree

La racine / et la page de connexion menent a /console. Les pages HTML historiques (/dashboard, /logs) redirigent vers l'ecran equivalent : une seule interface, pas deux qui divergent. Le parametre next= de la page de connexion n'accepte qu'un chemin interne, jamais une adresse absolue.

Listes de trafic legitime

L'ecran « Listes » montre les trois listes qui innocentent, avec leurs plages, leur provenance et leur etat.

  • Robots d'indexation, Prestataires de paiement, Webhooks de forge : les fichiers embarques restent la base, l'operateur ajoute ou retire des plages.
  • Une plage ajoutee est persistee dans le magasin partage (platform:allowlists) et appliquee dans la seconde : le decideur reconstruit son catalogue.
  • Une plage retiree des fichiers n'est pas effacee : le retrait est note, ce qui survit a la mise a jour de l'image. Une plage ajoutee, elle, s'efface pour de bon, sans laisser de trace de retrait.
  • Une entree illisible est refusee a l'ecriture : une liste d'exemption qui contient du texte fait passer des inconnus pour des robots.
  • Une exemption exempte du score, pas du WAF.

Deux garde-fous. La verification se fait par adresse, jamais par agent utilisateur : un en-tete se copie en une ligne, une plage publiee non. Et une seule liste ne suffit pas : l'adresse doit tomber dans une plage publiee par l'editeur.

Tableau de bord

La page repond a trois questions : ce qui passe, ce qui est bloque, par qui.

  • cinq indicateurs : requetes, bloquees et leur part, latence mediane avec p95, agents distincts, erreurs 5xx ;
  • la courbe empilee du trafic par verdict, avec le survol tranche par tranche et le pic de blocages annonce sous le graphique ;
  • l'etat reel du bord, lu dans status.features : une fonction n'est comptee appliquee que si l'operateur a cree son objet ;
  • adresses et chemins les plus demandes, avec la part bloquee de chacun ;
  • codes de reponse par famille et trafic par endpoint.

Le selecteur d'endpoint filtre tout le tableau cote serveur (/api/v1/stats?host=), il ne se contente pas de masquer des lignes. Il ne propose que des domaines declares, pas les noms d'hote vus dans les journaux.

La troisieme bande du dessin d'origine, les defis, n'existe pas : le bord ne les journalise pas separement. La legende le dit au lieu de dessiner une bande a zero.