Compare commits

..
Author SHA1 Message Date
gandalf 032611e544 merge: reunir cert sync (#1019) et les delais de televersement (#1030)
DEUX CORRECTIONS SUR DEUX BRANCHES, ET L UNE A EFFACE L AUTRE. Un paquet
secubox-haproxy construit depuis master a ete deploye sur la board par-dessus
un paquet issu de la branche 1019 : le verbe `cert sync` a disparu, et avec
lui le hook de renouvellement de certbot. Les certificats servis restaient
valables — c est bien ce qui rend la panne invisible — mais aucun renouvellement
n aurait plus ete recopie vers HAProxy.

Les deux entrees de changelog sont conservees : elles decrivent deux
corrections distinctes, toutes les deux vraies.
2026-08-13 19:00:55 +02:00
gandalf 8630e808a3 fix(haproxy,waf-ng): delais adaptes aux televersements du depot (ref #1030)
Un depot de 22 Mio mettait 116 s a s ecrire. HAProxy abandonnait a 30 s, sbxwaf
a 10 s — et le serveur, lui, TERMINAIT le travail. Chaque essai deposait a
nouveau.

LE DELAI EST RELEVE PAR REQUETE, PAS DANS `defaults`. La section `defaults`
protege TOUS les vhosts d un amont qui trainerait ; la relever aurait retire
cette protection a la centaine de vhosts qui n en ont pas besoin — payer pour
tous ce dont un seul a besoin.

DEUX LECONS PAYEES A L ESSAI. `http-request set-timeout` ne vit QUE dans un
backend — HAProxy refuse « proxy has no backend capability » cote frontend — et
il ne connait que `server` et `tunnel` : `set-timeout client` N EXISTE PAS. Les
deux refus ont ete rattrapes par la validation qui precede l installation : la
configuration vivante n a jamais bouge. Ce garde-fou vaut sa ligne.

`timeout client` reste a 30 s, et c est correct : c est un delai d INACTIVITE,
qu un televersement regulier n atteint jamais.

Cote WAF, `--upstream-timeout` passe de 10 s a 120 s : dix secondes sont trop
courtes pour tout point d entree qui ecrit sur disque.
2026-08-13 17:21:02 +02:00
gandalf 7c6b3969ab fix(haproxy): HAProxy servait des certificats que le renouvellement ne mettait jamais a jour (ref #1019)
certbot renouvelle dans /etc/letsencrypt/live ; HAProxy sert un fichier
CONCATENE dans /data/haproxy/certs. Rien ne recopiait l'un vers l'autre :
aucun hook n'existait, ni par certificat ni dans les repertoires globaux, tous
deux vides. Le verbe cert renew existant est ecrit pour acme.sh, absent de
cette board — du code mort ici.

Le defaut etait SILENCIEUX : le renouvellement reussit, les journaux sont
propres, le site repond, jusqu'au jour ou la copie servie expire des semaines
plus tard. Trois certificats sur six divergeaient sur gk2.

cert sync reconstruit les .pem depuis /etc/letsencrypt/live et compare le
CONTENU, pas les dates de fichier : un mtime recent ne prouve pas qu'un contenu
a change, et une copie touchee a la main aurait un mtime recent tout en servant
un certificat perime. Ecriture par renommage, sans quoi un .pem tronque ferait
echouer le rechargement pour TOUS les vhosts. Rechargement seulement si quelque
chose a change.

UNE GARDE AJOUTEE APRES L'ESSAI A BLANC, qui a montre le piege : deux
certificats servis expiraient PLUS TARD que la copie de letsencrypt. Le premier
jet les aurait ecrases et aurait RACCOURCI leur duree de vie. Un .pem servi peut
venir d'ailleurs qu'ACME ; la source n'est pas autoritaire par nature, seule la
date d'expiration l'est. Avec la garde, 3 mises a jour deviennent 1.

Un hook de deploiement certbot appelle cert sync apres chaque renouvellement
reussi, livre par le paquet — sinon il disparaitrait a la reinstallation.
2026-08-13 07:12:25 +02:00
10 changed files with 248 additions and 411 deletions
+70
View File
@@ -1,3 +1,73 @@
secubox-haproxy (1.8.0-1~bookworm1) bookworm; urgency=high
* Cette version REUNIT deux corrections qui vivaient sur des branches
separees, et dont l'une avait efface l'autre sur la board : un paquet
construit depuis master a fait perdre le verbe `cert sync` de la 1.6.4,
jamais fusionne. Le renouvellement automatique des certificats etait donc
casse en silence — les certificats servis restaient valables, mais rien ne
recopiait plus les renouvellements vers HAProxy.
secubox-haproxy (1.7.1-1~bookworm1) bookworm; urgency=high
* HAProxy servait des certificats que le renouvellement ne mettait jamais a
jour (ref #1019). certbot renouvelle dans /etc/letsencrypt/live ; HAProxy
sert un fichier CONCATENE dans /data/haproxy/certs. Rien ne recopiait l'un
vers l'autre : aucun hook n'existait, ni par certificat ni dans les
repertoires globaux, tous deux vides.
* Le defaut etait SILENCIEUX. Le renouvellement reussit, les journaux de
certbot sont propres, le site repond — jusqu'au jour ou la copie servie
expire, des semaines plus tard, et le lien de cause a effet est alors
indechiffrable.
* Constate sur gk2 : trois certificats sur six divergeaient, dont un qui
serait tombe le 14 octobre alors qu'une version valide jusqu'au 2 novembre
dormait deja sur le disque, inutilisee.
* Nouveau `haproxyctl cert sync` : reconstruit les .pem depuis
/etc/letsencrypt/live, compare le CONTENU et non les dates — un mtime plus
recent ne prouve pas qu'un fichier a change, et une copie touchee a la main
aurait un mtime recent tout en servant un certificat perime. Ecriture par
renommage, sans quoi un .pem tronque ferait echouer le rechargement pour
TOUS les vhosts. Ne recharge que si quelque chose a change.
* Un hook de deploiement certbot l'appelle apres chaque renouvellement reussi.
Livre par le paquet, pas pose a la main — sans quoi il disparaitrait a la
prochaine reinstallation, comme le reste.
* `cert sync` NE RACCOURCIT JAMAIS la vie d'un certificat servi. Le premier
jet ecrasait des que le contenu differait ; l'essai a blanc sur gk2 a montre
le piege : deux certificats servis expiraient PLUS TARD que la copie de
/etc/letsencrypt (live.maegia.tv, 4 novembre contre 14 octobre). Les
synchroniser aurait raccourci leur duree de vie — l'inverse du but. Un .pem
servi peut venir d'ailleurs qu'ACME (joker, autre outil, depot manuel) : la
source n'est pas autoritaire par nature, seule la date d'expiration l'est.
* `cert renew` reste ecrit pour acme.sh, absent de cette board : c'est du
code mort ici, laisse tel quel plutot que retire a l'aveugle.
-- Gerald Kerma <devel@cybermind.fr> Thu, 13 Aug 2026 10:00:00 +0200
secubox-haproxy (1.7.2-1~bookworm1) bookworm; urgency=medium
* DELAI RELEVE PAR REQUETE POUR LES TELEVERSEMENTS DU DEPOT (ref #1030). La
section `defaults` porte `timeout client/server 30s`, et c'est bien : elle
protege TOUS les vhosts d'un amont qui trainerait. Mais un depot de 22 Mio
a mis 116 s a s'ecrire — HAProxy abandonnait a 30, le deposant voyait un
504, ET LE SERVEUR TERMINAIT LE TRAVAIL. Chaque essai deposait a nouveau.
* `http-request set-timeout` (HAProxy >= 2.4, la board est en 2.6) releve le
delai POUR CES REQUETES SEULEMENT. Relever `defaults` aurait couvert le cas
en retirant la protection a la centaine de vhosts qui n'en ont pas besoin —
payer pour tous ce dont un seul a besoin.
* DEUX LECONS PAYEES A L'ESSAI. `http-request set-timeout` ne vit QUE dans un
backend — HAProxy refuse « proxy has no backend capability » cote frontend —
et il ne connait que `server` et `tunnel` : `set-timeout client` N'EXISTE
PAS. Les deux refus ont ete rattrapes par la validation qui precede
l'installation : la configuration vivante n'a jamais bouge. Ce garde-fou
vaut sa ligne.
* `timeout client` reste a 30 s, et c'est correct : c'est un delai
d'INACTIVITE, qu'un televersement regulier n'atteint jamais.
* L'ACL porte sur le CHEMIN, pas sur l'hote : deux `acl` de meme nom sont un
OU chez HAProxy, pas un ET. Nommer l'hote ET le chemin aurait releve le
delai pour tout hote contenant « depot », ou pour ce chemin sur n'importe
quel hote — l'inverse de ce qu'on cherche.
-- Gerald Kerma <devel@cybermind.fr> Thu, 13 Aug 2026 17:45:00 +0200
secubox-haproxy (1.6.3-1~bookworm1) bookworm; urgency=medium
* `vhost add` est idempotent (ref #1015). Il appendait sans jamais regarder
+6
View File
@@ -10,6 +10,12 @@
override_dh_usrlocal:
override_dh_auto_install:
# Hook de deploiement certbot (#1019). Sans lui, HAProxy sert des copies que
# le renouvellement ne met jamais a jour : trois certificats sur six
# divergeaient sur gk2, dont un qui serait tombe alors qu'une version valide
# dormait deja sur le disque.
install -d debian/secubox-haproxy/etc/letsencrypt/renewal-hooks/deploy
install -m 0755 letsencrypt-hooks/secubox-haproxy debian/secubox-haproxy/etc/letsencrypt/renewal-hooks/deploy/secubox-haproxy
install -d debian/secubox-haproxy/usr/lib/secubox/haproxy/
cp -r api debian/secubox-haproxy/usr/lib/secubox/haproxy/
install -d debian/secubox-haproxy/usr/share/secubox/www
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/secubox-haproxy — #1019
#
# certbot renouvelle dans /etc/letsencrypt/live ; HAProxy sert un fichier
# CONCATENE (chaine + cle) dans /data/haproxy/certs. Rien ne recopiait l'un vers
# l'autre : trois certificats sur six divergeaient sur gk2, dont un qui serait
# tombe le 14 octobre alors qu'une version valide jusqu'au 2 novembre dormait
# deja sur le disque.
#
# Le defaut etait SILENCIEUX : le renouvellement reussit, les journaux de
# certbot sont propres, le site repond — jusqu'au jour ou la copie servie
# expire, des semaines plus tard. Le lien de cause a effet est alors
# indechiffrable.
#
# Ce hook est un DEPLOY hook : certbot ne l'appelle qu'apres un renouvellement
# reussi, jamais sur un simple controle. `cert sync` ne recharge HAProxy que si
# un fichier a change, donc ce hook ne coupe aucune connexion pour rien.
set -eu
exec /usr/sbin/haproxyctl cert sync
+114
View File
@@ -727,6 +727,99 @@ cmd_cert_renew() {
fi
}
cmd_cert_sync() {
# Reconstruit les .pem servis par HAProxy depuis /etc/letsencrypt/live (#1019).
#
# POURQUOI CE VERBE EXISTE. certbot renouvelle dans /etc/letsencrypt/live ;
# HAProxy sert un fichier CONCATENE (chaine + cle) dans $CERTS_DIR. Rien ne
# recopiait l'un vers l'autre : aucun hook de renouvellement n'existait, et
# `cert renew` ci-dessus est ecrit pour acme.sh, absent de cette board.
#
# Le defaut est SILENCIEUX. Le renouvellement reussit, les journaux sont
# propres, le site repond — jusqu'au jour ou la copie servie expire, des
# semaines plus tard. Le lien de cause a effet est alors indechiffrable.
#
# Constate sur gk2 : trois certificats sur six divergeaient, dont un qui
# serait tombe le 14 octobre alors qu'une version valide jusqu'au 2 novembre
# dormait deja sur le disque.
local ecrire=1
[ "${1:-}" = "--dry-run" ] && ecrire=0
local live_root="/etc/letsencrypt/live"
[ -d "$live_root" ] || { log "cert-sync: pas de certbot sur cette board"; return 0; }
mkdir -p "$CERTS_DIR"
local change=0 vus=0
for d in "$live_root"/*/; do
[ -d "$d" ] || continue
local nom chaine cle cible
nom=$(basename "$d")
chaine="$d/fullchain.pem"
cle="$d/privkey.pem"
[ -f "$chaine" ] && [ -f "$cle" ] || continue
vus=$((vus + 1))
cible="$CERTS_DIR/$nom.pem"
# ON COMPARE LE CONTENU, PAS LES DATES. Un mtime plus recent ne prouve
# pas que le contenu a change — et une copie touchee a la main aurait un
# mtime plus recent tout en servant un certificat perime.
local somme_src somme_dst
somme_src=$(cat "$chaine" "$cle" | sha256sum | cut -d' ' -f1)
somme_dst=$([ -f "$cible" ] && sha256sum "$cible" | cut -d' ' -f1 || echo "")
[ "$somme_src" = "$somme_dst" ] && continue
local fin_src fin_dst
fin_src=$(openssl x509 -in "$chaine" -noout -enddate 2>/dev/null | cut -d= -f2)
fin_dst=$([ -f "$cible" ] && openssl x509 -in "$cible" -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
# ON NE RACCOURCIT JAMAIS LA VIE D'UN CERTIFICAT SERVI.
#
# Le premier jet ecrasait des que le contenu differait. L'essai a blanc
# sur gk2 a montre le piege : deux certificats servis expiraient PLUS
# TARD que la copie de /etc/letsencrypt (live.maegia.tv, 4 novembre
# contre 14 octobre). Les synchroniser aurait raccourci leur duree de
# vie — l'inverse exact du but.
#
# Un .pem servi peut venir d'ailleurs qu'ACME : joker, autre outil,
# depot manuel. La source n'est donc pas autoritaire par nature ; seule
# la date d'expiration l'est.
if [ -n "$fin_dst" ]; then
local ts_src ts_dst
ts_src=$(date -d "$fin_src" +%s 2>/dev/null || echo 0)
ts_dst=$(date -d "$fin_dst" +%s 2>/dev/null || echo 0)
if [ "$ts_src" -le "$ts_dst" ]; then
log "cert-sync: $nom — IGNORE, le certificat servi expire plus tard ($fin_dst) que la source ($fin_src)"
continue
fi
fi
change=$((change + 1))
log "cert-sync: $nom — servi jusqu'au ${fin_dst:-absent}, remplace par $fin_src"
[ "$ecrire" = "1" ] || continue
# Ecriture par fichier temporaire puis renommage : HAProxy peut relire le
# repertoire a tout instant, et un .pem tronque le ferait echouer au
# rechargement — donc tomber pour TOUS les vhosts, pas seulement celui-ci.
local tmp="$cible.nouveau"
cat "$chaine" "$cle" > "$tmp" || { error "cert-sync: ecriture $nom impossible"; rm -f "$tmp"; continue; }
chmod 0600 "$tmp"
mv "$tmp" "$cible"
done
if [ "$ecrire" = "0" ]; then
log "cert-sync: $vus certificat(s), $change a mettre a jour (lecture seule)"
return 0
fi
log "cert-sync: $vus certificat(s), $change mis a jour"
# ON NE RECHARGE QUE SI QUELQUE CHOSE A CHANGE. Recharger a chaque passage
# coupe les connexions en cours sans raison, et ce verbe est appele apres
# CHAQUE renouvellement — donc souvent, pour rien la plupart du temps.
[ "$change" -gt 0 ] && cmd_reload
return 0
}
# ─────────────────────────────────────────────────────────────────────
# CONFIG GENERATION
# ─────────────────────────────────────────────────────────────────────
@@ -806,6 +899,7 @@ frontend http-in
# garde anti-dérive refusait de régénérer — elle protégeait exactement ça.
acl is_acme_challenge path_beg /.well-known/acme-challenge/
use_backend acme_challenge if is_acme_challenge
EOF
if webui_regex=$(_fetch_webui_regex); then
@@ -935,6 +1029,25 @@ EOF
# WAF Inspector Backend (mitmproxy LXC at $waf_ip:$waf_port)
backend mitmproxy_inspector
mode http
# TELEVERSEMENTS LONGS : DELAI SERVEUR RELEVE PAR REQUETE (#1030).
#
# La section `defaults` porte `timeout server 30s`, et c'est bien : elle
# protege TOUS les vhosts d'un amont qui trainerait. Mais un depot de 22 Mio
# a mis 116 s a s'ecrire sur cette board — HAProxy abandonnait a 30, le
# deposant voyait un 504, ET LE SERVEUR TERMINAIT LE TRAVAIL. Chaque essai
# deposait a nouveau : trois depots pour une seule intention.
#
# DEUX LECONS PAYEES A L'ESSAI. `set-timeout` ne vit QUE dans un backend —
# HAProxy refuse « proxy has no backend capability » cote frontend — et il
# ne connait que `server` et `tunnel` : `set-timeout client` n'existe pas.
# `timeout client` reste donc a 30 s, ce qui convient : c'est un delai
# d'INACTIVITE, et un televersement regulier ne l'atteint jamais.
#
# Relever `defaults` aurait couvert le cas en retirant la protection a la
# centaine de vhosts qui n'en ont pas besoin — payer pour tous ce dont un
# seul a besoin.
acl est_depot_long path_beg /api/v1/droplet/depot
http-request set-timeout server 1h if est_depot_long
option forwardfor
http-request set-header X-Real-IP %[src]
# %[query] rend la chaine de requete SANS le « ? » qui l'introduit : une
@@ -1273,6 +1386,7 @@ case "${1:-}" in
cert)
case "${2:-}" in
list) cmd_cert_list ;;
sync) cmd_cert_sync "${3:-}" ;;
add) cmd_cert_add "$3" "$4" ;;
renew) cmd_cert_renew ;;
*) echo "Usage: haproxyctl cert {list|add|renew} [domain]" ;;
@@ -590,35 +590,6 @@ func (s *Server) handler() http.Handler {
// mirrors Python media_cache.py r.pretty_url which includes the host).
if s.mediaCache != nil && r.Method == http.MethodGet {
vhostCacheURL := "https://" + r.Host + r.URL.RequestURI()
// REVALIDER AVANT DE SERVIR (#1031).
//
// `Get` ne sait que verifier une expiration. Un fichier remplace sur
// disque restait donc masque jusqu'a la fin de son TTL — constate sur
// anibal-amiot.fr, ou un `app.js` mis a jour par `git pull` etait
// servi dans sa version d'avant, sur les six domaines a la fois. Une
// synchronisation aux cinq minutes ne sert a rien si le cache repond
// l'ancienne version pendant l'heure qui suit.
//
// LE COUT EST UN ALLER-RETOUR VERS UN NGINX LOCAL — quelques centaines
// d'octets pour un 304. Ce que le cache evite reste l'essentiel : le
// transfert et l'inspection des gros medias.
//
// SANS VALIDATEUR, ON S'EN REMET AU TTL — faute de mieux, pas par
// confiance. Un amont qui ne pose ni ETag ni Last-Modified ne permet
// aucune question ; refuser alors tout cache le rendrait inutile
// precisement pour les services qui n'en posent pas, c'est-a-dire
// souvent ceux qui servent les gros medias que ce cache existe pour
// eviter de retransferer. Le TTL borne alors la peremption, ce qui
// est le compromis HTTP habituel.
//
// nginx, lui, pose les deux sur tout fichier statique : les sites
// metablog — ceux qu'un `git pull` met a jour — sont donc revalides.
if etag, lm := s.mediaCache.Validateurs(vhostCacheURL); etag != "" || lm != "" {
if frais := amontInchange(ip, port, r, etag, lm); !frais {
s.mediaCache.Invalide(vhostCacheURL)
}
}
if cachedBody, cachedHdr, hit := s.mediaCache.Get(vhostCacheURL, r.Header.Get("Accept-Encoding")); hit {
for k, vs := range cachedHdr {
for _, v := range vs {
@@ -69,17 +69,6 @@ type cacheEntry struct {
atime int64 // unix timestamp of last access (for LRU eviction)
ct string
ce string // Content-Encoding of the stored body ("" = identity)
// Validateurs de l'amont, pour la revalidation conditionnelle (#1031).
//
// SANS EUX, LE CACHE NE SAIT QUE VIEILLIR. `Get` servait toute entree non
// expiree, donc un fichier modifie sur disque restait masque jusqu'a la fin
// de son heure de TTL — constate sur anibal-amiot.fr, ou un `app.js`
// remplace par un `git pull` etait servi dans sa version d'avant. Une
// synchronisation aux cinq minutes ne sert a rien si le cache repond
// l'ancienne version pendant l'heure qui suit.
etag string
lastMod string
}
// CacheStats is a snapshot of MediaCache counters.
@@ -171,20 +160,16 @@ func (m *MediaCache) loadIndex() {
}
metaPath := bodyPath + ".m"
var meta struct {
CT string `json:"ct"`
CE string `json:"ce"`
Exp int64 `json:"exp"`
ETag string `json:"etag"`
LastMod string `json:"lm"`
CT string `json:"ct"`
CE string `json:"ce"`
Exp int64 `json:"exp"`
}
if raw, err := os.ReadFile(metaPath); err == nil {
_ = json.Unmarshal(raw, &meta)
}
e := &cacheEntry{
size: info.Size(),
exp: meta.Exp,
etag: meta.ETag,
lastMod: meta.LastMod,
size: info.Size(),
exp: meta.Exp,
// mtime is used deliberately as the LRU recency proxy.
// atime is unreliable on most Linux filesystems (relatime
// mount option suppresses most atime updates), so we use
@@ -277,44 +262,6 @@ func encodingAccepted(acceptEncoding, coding string) bool {
// exists that the client can decode. ok=false means cache miss (or expired, or
// the stored representation is encoded in a way the client did not advertise via
// acceptEncoding). Fail-open: I/O errors → miss.
// Validateurs rend l'ETag et le Last-Modified memorises pour cette URL (#1031).
//
// A QUOI ILS SERVENT : construire une requete conditionnelle vers l'amont, pour
// lui demander si le fichier a change plutot que de le supposer. Sans reponse
// (entree absente, ou amont qui n'en pose aucun), on rend deux chaines vides et
// l'appelant traite le cas comme un cache a rafraichir — l'ignorance conduit a
// redemander, jamais a servir du perime.
func (m *MediaCache) Validateurs(url string) (etag, lastMod string) {
if m == nil {
return "", ""
}
m.mu.Lock()
defer m.mu.Unlock()
if e, ok := m.index[cacheKey(url)]; ok {
return e.etag, e.lastMod
}
return "", ""
}
// Invalide retire une entree du cache (#1031). Appelee quand l'amont a repondu
// autre chose qu'un 304 : le contenu memorise n'est plus le bon, et le garder
// une seconde de plus ne peut que tromper.
func (m *MediaCache) Invalide(url string) {
if m == nil {
return
}
key := cacheKey(url)
m.mu.Lock()
if e, ok := m.index[key]; ok {
m.total -= e.size
delete(m.index, key)
}
m.mu.Unlock()
bodyPath, metaPath := m.paths(key)
_ = os.Remove(bodyPath)
_ = os.Remove(metaPath)
}
func (m *MediaCache) Get(url, acceptEncoding string) (body []byte, hdr http.Header, ok bool) {
key := cacheKey(url)
now := m.nowFn().Unix()
@@ -476,25 +423,15 @@ func (m *MediaCache) MaybeStore(req *http.Request, resp *http.Response, body []b
_ = os.Remove(tmp)
return
}
// LES VALIDATEURS SONT CE QUI PERMET DE NE PAS SERVIR DU PERIME. nginx pose
// un ETag et un Last-Modified sur tout fichier statique ; les garder permet
// de demander a l'amont « a-t-il change ? » plutot que de le supposer.
etagClean := strings.TrimSpace(resp.Header.Get("ETag"))
lmClean := strings.TrimSpace(resp.Header.Get("Last-Modified"))
meta := struct {
CT string `json:"ct"`
CE string `json:"ce"`
Exp int64 `json:"exp"`
ETag string `json:"etag,omitempty"`
LastMod string `json:"lm,omitempty"`
URL string `json:"url"`
CT string `json:"ct"`
CE string `json:"ce"`
Exp int64 `json:"exp"`
URL string `json:"url"`
}{
CT: ctClean,
CE: ceClean,
Exp: exp,
ETag: etagClean,
LastMod: lmClean,
CT: ctClean,
CE: ceClean,
Exp: exp,
URL: func() string {
if len(rawURL) > 300 {
return rawURL[:300]
@@ -516,13 +453,11 @@ func (m *MediaCache) MaybeStore(req *http.Request, resp *http.Response, body []b
}
m.total += newSize - old
m.index[key] = &cacheEntry{
size: newSize,
exp: exp,
atime: now,
ct: ctClean,
ce: ceClean,
etag: etagClean,
lastMod: lmClean,
size: newSize,
exp: exp,
atime: now,
ct: ctClean,
ce: ceClean,
}
m.evictIfNeeded()
m.mu.Unlock()
@@ -1,74 +0,0 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: sbxwaf — revalidation conditionnelle du cache média (#1031)
//
// LE CACHE NE SAVAIT QUE VIEILLIR. `MediaCache.Get` servait toute entrée non
// expirée : un fichier remplacé sur disque restait donc masqué jusqu'à la fin
// de son TTL. Constaté sur anibal-amiot.fr — un `app.js` mis à jour par
// `git pull` était servi dans sa version d'avant, sur les six domaines à la
// fois, pendant que le disque et nginx portaient déjà le bon.
//
// Une synchronisation aux cinq minutes ne sert à rien si le cache répond
// l'ancienne version pendant l'heure qui suit.
package main
import (
"net"
"net/http"
"strconv"
"time"
)
// delaiRevalidation borne l'attente. L'amont est un nginx local : au-delà de
// deux secondes il ne répond pas, et faire patienter le visiteur pour décider
// de la fraîcheur d'une image serait un mauvais échange.
const delaiRevalidation = 2 * time.Second
// amontInchange demande à l'amont si sa version est toujours celle qu'on a.
//
// Rend true UNIQUEMENT sur un 304 franc. Tout le reste — 200, erreur, délai
// dépassé, amont muet — rend false, donc « je ne sais pas », donc on
// réinterroge. C'EST LE SENS DE LA PRUDENCE ICI : servir du périmé est
// invisible et dure des heures ; un aller-retour de trop coûte une milliseconde.
func amontInchange(ip string, port int, r *http.Request, etag, lastMod string) bool {
if etag == "" && lastMod == "" {
return false
}
cible := "http://" + net.JoinHostPort(ip, strconv.Itoa(port)) + r.URL.RequestURI()
req, err := http.NewRequest(http.MethodGet, cible, nil)
if err != nil {
return false
}
// L'hote est repris tel quel : l'amont sert plusieurs vhosts sur le meme
// port, et sans cet en-tete il repondrait pour un autre site — on
// revaliderait alors contre le mauvais fichier.
req.Host = r.Host
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if lastMod != "" {
req.Header.Set("If-Modified-Since", lastMod)
}
// Pas de compression : on ne veut qu'un code de statut, et negocier un
// encodage exposerait a recevoir un corps qu'il faudrait lire pour rien.
req.Header.Set("Accept-Encoding", "identity")
cl := &http.Client{
Timeout: delaiRevalidation,
// ON NE SUIT PAS LES REDIRECTIONS. Un 301 vers un autre chemin n'est
// pas « ce fichier n'a pas change » : c'est autre chose, et le suivre
// masquerait le changement qu'on cherche justement a detecter.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := cl.Do(req)
if err != nil {
return false
}
// Le corps d'un 304 est vide ; celui d'un 200 ne nous interesse pas ici —
// le chemin normal du proxy le retransmettra. On ferme sans lire.
_ = resp.Body.Close()
return resp.StatusCode == http.StatusNotModified
}
@@ -1,195 +0,0 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Revalidation conditionnelle du cache media (#1031).
//
// Le defaut d origine : un `app.js` remplace sur disque restait servi dans sa
// version d avant jusqu a la fin de son TTL d une heure.
package main
import (
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func amontEssai(t *testing.T, h http.HandlerFunc) (string, int, func()) {
t.Helper()
srv := httptest.NewServer(h)
u := strings.TrimPrefix(srv.URL, "http://")
hote, p, err := net.SplitHostPort(u)
if err != nil {
t.Fatalf("adresse : %v", err)
}
n, _ := strconv.Atoi(p)
return hote, n, srv.Close
}
func requete(chemin, hote string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://x"+chemin, nil)
r.Host = hote
return r
}
func TestUn304SignifieInchange(t *testing.T) {
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"abc"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
})
defer stop()
if !amontInchange(ip, port, requete("/app.js", "site.example"), `"abc"`, "") {
t.Fatal("un 304 doit valoir « inchange »")
}
}
func TestUn200SignifieChange(t *testing.T) {
// LE CAS DU DEFAUT : le fichier a ete remplace, l ETag ne correspond plus.
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("nouveau contenu"))
})
defer stop()
if amontInchange(ip, port, requete("/app.js", "site.example"), `"vieux"`, "") {
t.Fatal("un 200 doit valoir « change »")
}
}
func TestSansValidateurOnNeSupposeRien(t *testing.T) {
// L ignorance doit conduire a redemander, jamais a servir du perime.
appele := false
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
appele = true
w.WriteHeader(http.StatusNotModified)
})
defer stop()
if amontInchange(ip, port, requete("/a.png", "site.example"), "", "") {
t.Fatal("sans validateur, on ne peut pas conclure « inchange »")
}
if appele {
t.Error("sans validateur, l amont ne doit meme pas etre interroge")
}
}
func TestAmontMuetVautChange(t *testing.T) {
// Un amont injoignable ne prouve pas que le cache est bon. Servir du
// perime est invisible et dure des heures ; un aller-retour de trop coute
// une milliseconde.
if amontInchange("127.0.0.1", 1, requete("/a.png", "site.example"), `"abc"`, "") {
t.Fatal("un amont muet ne doit jamais valoir « inchange »")
}
}
func TestLHoteEstTransmis(t *testing.T) {
// L amont sert plusieurs vhosts sur le meme port : sans l en-tete Host, on
// revaliderait contre le fichier d un autre site.
var vu string
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
vu = r.Host
w.WriteHeader(http.StatusNotModified)
})
defer stop()
amontInchange(ip, port, requete("/a.png", "anibal-amiot.fr"), `"abc"`, "")
if vu != "anibal-amiot.fr" {
t.Errorf("Host transmis = %q, veut anibal-amiot.fr", vu)
}
}
func TestUneRedirectionNEstPasUneFraicheur(t *testing.T) {
// Un 301 vers un autre chemin n est pas « ce fichier n a pas change » :
// le suivre masquerait le changement qu on cherche a detecter.
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ailleurs", http.StatusMovedPermanently)
})
defer stop()
if amontInchange(ip, port, requete("/a.png", "site.example"), `"abc"`, "") {
t.Fatal("une redirection ne doit pas valoir « inchange »")
}
}
// ── Le defaut de bout en bout ────────────────────────────────────────────
// Une entree VALIDEE dont l amont a change ne doit plus etre servie : c est
// exactement anibal-amiot.fr, ou un app.js remplace par `git pull` restait
// masque par le cache pendant une heure.
func TestEntreeValideeEtAmontChangeNEstPasServie(t *testing.T) {
dir := t.TempDir()
mc := NewMediaCache(dir)
const u = "https://site.example/app.js"
// On memorise « ancien contenu », avec un validateur.
req := httptest.NewRequest(http.MethodGet, u, nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/javascript"},
"Etag": []string{`"ancien"`},
},
Request: req,
}
mc.MaybeStore(req, resp, []byte("ancien contenu"), u)
etag, lm := mc.Validateurs(u)
if etag != `"ancien"` {
t.Fatalf("validateur non memorise : %q / %q", etag, lm)
}
// L amont repond 200 : le fichier a change.
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"neuf"`)
w.WriteHeader(http.StatusOK)
})
defer stop()
if amontInchange(ip, port, requete("/app.js", "site.example"), etag, lm) {
t.Fatal("l amont a change : la revalidation doit le dire")
}
mc.Invalide(u)
if _, _, ok := mc.Get(u, ""); ok {
t.Fatal("apres invalidation, l entree ne doit plus etre servie")
}
}
// Et l inverse : une entree validee dont l amont n a PAS change reste servie —
// sans quoi la correction couterait le cache tout entier.
func TestEntreeValideeEtAmontInchangeResteServie(t *testing.T) {
dir := t.TempDir()
mc := NewMediaCache(dir)
const u = "https://site.example/logo.png"
req := httptest.NewRequest(http.MethodGet, u, nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"image/png"},
"Etag": []string{`"stable"`},
},
Request: req,
}
mc.MaybeStore(req, resp, []byte("des pixels"), u)
ip, port, stop := amontEssai(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"stable"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
})
defer stop()
etag, lm := mc.Validateurs(u)
if !amontInchange(ip, port, requete("/logo.png", "site.example"), etag, lm) {
t.Fatal("l amont n a pas change : le cache doit rester valable")
}
body, _, ok := mc.Get(u, "")
if !ok || string(body) != "des pixels" {
t.Fatalf("l entree doit toujours etre servie, got ok=%v", ok)
}
}
+10 -31
View File
@@ -1,36 +1,15 @@
secubox-waf-ng (1.5.0-1~bookworm1) bookworm; urgency=high
secubox-waf-ng (1.4.1-1~bookworm1) bookworm; urgency=medium
* LE CACHE MEDIA REVALIDE, IL NE FAIT PLUS QUE VIEILLIR (ref #1031).
`MediaCache.Get` servait toute entree non expiree : un fichier remplace sur
disque restait donc masque jusqu'a la fin de son TTL d'une heure. Constate
sur anibal-amiot.fr — un `app.js` mis a jour par `git pull` etait servi dans
sa version d'avant, sur les six domaines a la fois, pendant que le disque ET
nginx portaient deja le bon. Le cache masquait exactement le dernier commit.
* L'ETag et le Last-Modified de l'amont sont memorises, et chaque succes de
cache declenche une requete conditionnelle : 304 → on sert le cache, chemin
rapide preserve ; toute autre reponse → l'entree est invalidee et l'amont
refait foi.
* SEUL UN 304 FRANC VAUT « INCHANGE ». Une erreur, un delai depasse, un amont
muet rendent « je ne sais pas », donc on reinterroge : servir du perime est
invisible et dure des heures, un aller-retour de trop coute une
milliseconde.
* UNE REDIRECTION N'EST PAS UNE FRAICHEUR : un 301 vers un autre chemin
masquerait le changement qu'on cherche justement a detecter. Les
redirections ne sont donc pas suivies.
* SANS VALIDATEUR, ON S'EN REMET AU TTL — faute de mieux, pas par confiance.
Un amont qui ne pose ni ETag ni Last-Modified ne permet aucune question ;
refuser alors tout cache le rendrait inutile precisement pour les services
qui n'en posent pas, souvent ceux qui servent les gros medias que ce cache
existe pour eviter de retransferer. nginx, lui, pose les deux sur tout
fichier statique : les sites metablog sont donc revalides.
* L'en-tete Host est transmis a la revalidation : l'amont sert plusieurs
vhosts sur le meme port, et sans lui on revaliderait contre le fichier d'un
autre site.
* Sept tests, dont le defaut de bout en bout et son inverse — une entree
validee dont l'amont n'a PAS change doit rester servie, sans quoi la
correction couterait le cache tout entier.
* `--upstream-timeout` porte de 10 s a 120 s (ref #1030). Dix secondes sont
trop courtes pour tout point d'entree qui ecrit sur disque : un depot de
22 Mio a mis 116 s, le WAF abandonnait a 10 s, le deposant voyait un 504 —
et le serveur, lui, TERMINAIT le travail. Chaque essai deposait a nouveau.
* 120 s et non « une minute de plus par securite » : c'est la duree au-dela
de laquelle plus rien de sain ne se passe sur un point d'entree qui accuse
reception avant d'envoyer son alerte. Assez pour un televersement honnete,
assez court pour qu'un amont reellement mort soit encore constate comme tel.
-- Gerald Kerma <devel@cybermind.fr> Thu, 13 Aug 2026 18:40:00 +0200
-- Gerald Kerma <devel@cybermind.fr> Thu, 13 Aug 2026 17:45:00 +0200
secubox-waf-ng (1.3.2-1~bookworm1) bookworm; urgency=high
@@ -65,8 +65,20 @@ ExecStartPre=+/bin/chmod 0750 /var/log/secubox/cookie-audit
ExecStartPre=+/bin/mkdir -p /var/log/secubox/waf
ExecStartPre=+/bin/chown -R secubox-waf:secubox-waf /var/log/secubox/waf
ExecStartPre=+/bin/chmod 0750 /var/log/secubox/waf
# DELAI AMONT : 10 s PAR DEFAUT, TROP COURT POUR QUI ECRIT SUR DISQUE (#1030).
#
# Un depot de 22 Mio a mis 116 s a s'ecrire sur cette board. Le WAF abandonnait
# a 10 s, le deposant voyait un 504 — ET LE SERVEUR TERMINAIT LE TRAVAIL. Chaque
# essai deposait donc a nouveau : trois depots pour une seule intention. Un
# delai mal regle devenait un amplificateur du volume recu.
#
# 120 s : assez pour un televersement honnete, assez court pour qu'un amont
# reellement mort soit encore constate comme tel. Ce n'est pas « une minute de
# plus par securite » — c'est la duree au-dela de laquelle plus rien de sain ne
# se passe sur un point d'entree qui accuse reception avant d'envoyer l'alerte.
ExecStart=/usr/sbin/sbxwaf \
--listen 127.0.0.1:8085 \
--upstream-timeout 120s \
--routes /etc/secubox/waf/haproxy-routes.json \
--rules /etc/secubox/waf/waf-rules.json \
--on-demand-vhosts /etc/secubox/waf/on-demand-vhosts.json \