mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-08-16 21:21:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
032611e544 | ||
|
|
8630e808a3 | ||
|
|
7c6b3969ab |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user