erplibre/script/qemu_cache/mitm_test.go
Mathieu Benoit 76ea0a2707 [FIX] cache qemu : une rafale de coupures ne condamne plus un hôte
A package manager opens several connections to one repository at once and
closes those it does not use. The cache saw three cut handshakes in the same
second, counted three refusals, and moved the host to an opaque tunnel, which
never consults the store. A client then waited four hours on that tunnel,
holding a socket the far end had closed, until its own deadline killed it.
Cuts closer than two seconds now count as one incident; a client that really
rejects the authority retries seconds later and is banned as before. A closing
tunnel is logged with its duration and bytes each way.

--- FR ---

Un gestionnaire de paquets ouvre plusieurs connexions de front vers un dépôt
et ferme celles dont il ne se sert pas. Le cache voyait trois poignées de main
coupées dans la même seconde, comptait trois refus, et passait l'hôte en
tunnel opaque, qui ne consulte jamais le magasin. Un client attendait alors
quatre heures sur ce tunnel, sur un socket que l'autre bout avait refermé,
jusqu'à son propre délai. Deux coupures à moins de deux secondes comptent
désormais pour un incident ; un client qui rejette vraiment l'autorité
réessaie et reste condamné. La fin d'un tunnel est journalisée.

Assisted-by: Claude Opus 5
2026-09-16 05:36:13 -04:00

556 lines
17 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func TestCreationEtRelectureDeLAutorite(t *testing.T) {
dir := t.TempDir()
ca1, err := LoadOrCreateCA(dir)
if err != nil {
t.Fatalf("création : %v", err)
}
// Relire ne doit PAS régénérer : une autorité neuve invaliderait les
// certificats de toutes les VM déjà configurées.
ca2, err := LoadOrCreateCA(dir)
if err != nil {
t.Fatalf("relecture : %v", err)
}
if ca1.Fingerprint() != ca2.Fingerprint() {
t.Errorf("l'autorité a été régénérée :\n %s\n %s",
ca1.Fingerprint(), ca2.Fingerprint())
}
if !ca1.cert.IsCA {
t.Error("le certificat n'est pas une autorité")
}
}
// La clé permet de se faire passer pour n'importe quel site auprès d'une VM
// qui approuve l'autorité : elle ne doit être lisible que par son
// propriétaire.
func TestCleDeLAutoriteEnMode0600(t *testing.T) {
dir := t.TempDir()
if _, err := LoadOrCreateCA(dir); err != nil {
t.Fatalf("création : %v", err)
}
st, err := os.Stat(filepath.Join(dir, "ca.key"))
if err != nil {
t.Fatalf("clé absente : %v", err)
}
if mode := st.Mode().Perm(); mode != 0o600 {
t.Errorf("clé en %o, attendu 600", mode)
}
// Le certificat, lui, part dans les VM : il doit rester lisible.
stc, err := os.Stat(CertPath(dir))
if err != nil {
t.Fatalf("certificat absent : %v", err)
}
if mode := stc.Mode().Perm(); mode != 0o644 {
t.Errorf("certificat en %o, attendu 644", mode)
}
}
// Une feuille doit se vérifier CONTRE l'autorité, sans quoi l'invité la
// rejette malgré la confiance accordée.
func TestFeuilleVerifieeParLAutorite(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
crt, err := ca.leafFor("miroir.example")
if err != nil {
t.Fatalf("feuille : %v", err)
}
leaf, err := x509.ParseCertificate(crt.Certificate[0])
if err != nil {
t.Fatalf("feuille illisible : %v", err)
}
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
if _, err := leaf.Verify(x509.VerifyOptions{
DNSName: "miroir.example",
Roots: pool,
}); err != nil {
t.Errorf("la feuille ne se vérifie pas : %v", err)
}
// La chaîne envoyée porte l'autorité après la feuille : un invité qui ne
// la connaît pas encore doit pouvoir la voir.
if len(crt.Certificate) != 2 {
t.Errorf("la chaîne porte %d certificats, attendu 2", len(crt.Certificate))
}
}
// Un hôte demandé deux fois ne coûte qu'une génération : une installation
// touche des dizaines d'hôtes.
func TestFeuilleGardeeEnMemoire(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
a, _ := ca.leafFor("miroir.example")
b, _ := ca.leafFor("miroir.example")
if a != b {
t.Error("la feuille est régénérée à chaque demande")
}
}
// Un SNI qui est une adresse IP ne peut pas aller dans un nom DNS.
func TestFeuillePourUneAdresseIP(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
crt, err := ca.leafFor("192.168.122.50")
if err != nil {
t.Fatalf("feuille : %v", err)
}
leaf, _ := x509.ParseCertificate(crt.Certificate[0])
if len(leaf.IPAddresses) != 1 {
t.Errorf("%d adresse(s) dans le certificat, attendu 1", len(leaf.IPAddresses))
}
if len(leaf.DNSNames) != 0 {
t.Errorf("une adresse IP est passée en nom DNS : %v", leaf.DNSNames)
}
}
// errRefus est l'alerte qu'envoie un client qui a REGARDÉ notre certificat.
var errRefus = errors.New("remote error: tls: unknown certificate authority")
// Le repli ne se déclare pas d'avance : un hôte qui refuse le certificat est
// retenu, et la requête suivante vers lui n'essaie plus de le déchiffrer.
func TestRefusRetenu(t *testing.T) {
r := NewRefusals(nil)
if r.Has("api.example") {
t.Fatal("un hôte est exclu avant tout refus")
}
r.Echec("api.example", errRefus)
if !r.Has("api.example") {
t.Error("le refus n'est pas retenu")
}
// Deux fois le même hôte ne double pas l'entrée.
r.Echec("api.example", errRefus)
if n := len(r.List()); n != 1 {
t.Errorf("%d hôtes retenus, attendu 1", n)
}
}
// Un suffixe couvre un domaine entier, sous-domaines compris.
func TestExclusionParSuffixe(t *testing.T) {
r := NewRefusals([]string{".snapcraft.io"})
for _, h := range []string{"api.snapcraft.io", "dashboard.snapcraft.io"} {
if !r.Has(h) {
t.Errorf("%s n'est pas couvert par le suffixe", h)
}
}
if r.Has("snapcraft.io.example.com") {
t.Error("le suffixe attrape un domaine qui ne fait que le contenir")
}
}
// Les hôtes dont l'épinglage est connu d'avance évitent de perdre une requête
// pour l'apprendre.
func TestExclusionsParDefaut(t *testing.T) {
r := NewRefusals(DefaultExclusions)
if !r.Has("api.snapcraft.io") {
t.Error("snapd n'est pas exclu d'avance, alors qu'il épingle")
}
}
// La casse d'un nom d'hôte n'a pas de sens en DNS.
func TestExclusionInsensibleALaCasse(t *testing.T) {
r := NewRefusals([]string{"API.Example"})
if !r.Has("api.example") {
t.Error("l'exclusion dépend de la casse")
}
}
// L'empreinte sert à vérifier de visu qu'une VM approuve BIEN cette autorité.
func TestEmpreinteLisible(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatalf("autorité : %v", err)
}
fp := ca.Fingerprint()
if n := strings.Count(fp, ":"); n != 31 {
t.Errorf("%d séparateurs, attendu 31 pour un SHA-256", n)
}
if fp != strings.ToUpper(fp) {
t.Errorf("empreinte en minuscules : %s", fp)
}
}
// Un premier enregistrement qui n'est pas une poignée de main TLS est refusé
// tôt : le port du 443 détourné ne reçoit rien d'autre.
func TestPremierEnregistrementNonTLS(t *testing.T) {
if _, err := peekSNI([]byte{0x17, 0x03, 0x03, 0x00, 0x01, 0x00}); err == nil {
t.Error("un enregistrement qui n'est pas un handshake est accepté")
}
}
// Une coupure isolée ne condamne pas ; une coupure QUI SE RÉPÈTE, oui.
//
// Les deux se ressemblent au serveur : npm rejette notre certificat sans
// envoyer d'alerte, si bien qu'on ne voit qu'un EOF — exactement ce que
// produit une VM qui démarre et coupe. Seule la répétition les sépare.
func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) {
r := NewRefusals(nil)
r.Rafale = -1 // chaque coupure compte : c'est le SEUIL qu'on éprouve ici
coupure := errors.New("read: connection reset by peer")
for i := 1; i < r.Seuil; i++ {
if r.Echec("miroir.example", coupure) {
t.Fatalf("condamné dès l'échec %d, seuil %d", i, r.Seuil)
}
}
if r.Has("miroir.example") {
t.Error("condamné avant le seuil")
}
}
func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) {
r := NewRefusals(nil)
// Des coupures ESPACÉES, telles qu'un client qui réessaie les produit :
// l'horloge avance entre chacune, sans faire dormir le test.
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
coupure := errors.New("EOF")
for i := 0; i < r.Seuil; i++ {
r.Echec("npm.example", coupure)
horloge = horloge.Add(10 * time.Second)
}
if !r.Has("npm.example") {
t.Error("un client qui échoue à chaque fois n'est jamais mis en" +
" tunnel : son installation s'arrêtera là")
}
}
// Un soupçon se rouvre ; une décision, jamais.
//
// Trois coupures de suite condamnent l'hôte, et c'est voulu — mais elles ne
// disent rien de ce que le client pense de notre autorité. Le garder banni
// pour toujours prive le cache de tout ce qu'il détient pour lui : un tunnel
// recopie des octets sans consulter le magasin. Un miroir de distribution
// banni sur une rafale de flux corrompus renvoie alors à l'amont jusqu'à ce
// qui est en réserve.
func TestUnRefusDeTransportSeRouvre(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
r.Rafale = -1
coupure := errors.New("local error: tls: bad record MAC")
for i := 0; i < r.Seuil; i++ {
r.Echec("miroir.example", coupure)
}
if !r.Has("miroir.example") {
t.Fatal("le seuil ne condamne plus")
}
time.Sleep(60 * time.Millisecond)
if r.Has("miroir.example") {
t.Error("un soupçon de transport ne se rouvre jamais")
}
if slices.Contains(r.List(), "miroir.example") {
t.Error("un refus expiré figure encore dans la liste")
}
}
// L'oubli rouvre le doute ENTIER : un hôte rouvert qui coupe une fois n'est pas
// recondamné sur-le-champ. Le compte d'avant l'oubli, s'il survivait, ferait
// d'une coupure isolée — un processus qui se termine en abandonnant ses
// poignées de main — un nouveau bannissement de toute la durée d'oubli.
func TestLOubliRemetLeCompteAZero(t *testing.T) {
// Has et List constatent l'oubli chacun de leur côté : les deux doivent
// rouvrir le compte.
lectures := map[string]func(r *Refusals, h string) bool{
"Has": func(r *Refusals, h string) bool { return r.Has(h) },
"List": func(r *Refusals, h string) bool { return slices.Contains(r.List(), h) },
}
for nom, lire := range lectures {
t.Run(nom, func(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
r.Rafale = -1
for i := 0; i < r.Seuil; i++ {
r.Echec("registre.example", io.EOF)
}
time.Sleep(60 * time.Millisecond)
if lire(r, "registre.example") {
t.Fatal("le soupçon ne s'est pas rouvert")
}
if r.Echec("registre.example", io.EOF) {
t.Error("une seule coupure après l'oubli recondamne l'hôte")
}
})
}
}
// Un hôte en tunnel dont l'amont ne répond pas est DÉCHIFFRÉ sur la même
// connexion : le tunnel ne pourrait que couper le client, alors que le magasin
// détient peut-être ce qu'il demande.
func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
// Une adresse qui refuse : l'écoute est fermée aussitôt ouverte.
ferme, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
coupee := ferme.Addr().String()
ferme.Close()
const hote = "registre.example.invalid"
r := NewRefusals(nil)
r.Rafale = -1
for i := 0; i < r.Seuil; i++ {
r.Echec(hote, io.EOF)
}
front := &TLSFront{
CA: ca, Proxy: proxyDeTest(t), Refusals: r,
Origine: func(net.Conn) (string, error) { return coupee, nil },
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go front.Serve(ln)
brut, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatal(err)
}
defer brut.Close()
brut.SetDeadline(time.Now().Add(5 * time.Second))
pool := x509.NewCertPool()
pool.AddCert(ca.cert)
tc := tls.Client(brut, &tls.Config{ServerName: hote, RootCAs: pool})
if err := tc.Handshake(); err != nil {
t.Fatalf("l'amont coupé laisse le client sans poignée de main : %v", err)
}
if !r.Has(hote) {
t.Error("le repli a effacé le soupçon : il ne vaut que pour cette" +
" connexion")
}
}
// Une ALERTE ne se rouvre pas, quel que soit le réglage d'oubli : le client a
// REGARDÉ notre certificat. Le ré-intercepter ferait échouer de nouveau
// l'installation qui le traverse.
func TestUneAlerteNeSeRouvreJamais(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = time.Millisecond
r.Echec("epingleur.example", errRefus)
time.Sleep(10 * time.Millisecond)
if !r.Has("epingleur.example") {
t.Error("une décision du client a été oubliée")
}
if !slices.Contains(r.List(), "epingleur.example") {
t.Error("une alerte a disparu de la liste")
}
}
// Le défaut n'est plus « jamais » : sans lui, une rafale condamne un hôte
// jusqu'au redémarrage du service.
func TestLOubliEstActifParDefaut(t *testing.T) {
if NewRefusals(nil).Oubli <= 0 {
t.Error("un refus de transport ne se rouvre jamais par défaut")
}
}
// Une réussite efface le compte : deux incidents éloignés ne doivent pas
// s'additionner jusqu'au seuil.
func TestUneReussiteEffaceLeCompte(t *testing.T) {
r := NewRefusals(nil)
r.Rafale = -1
coupure := errors.New("EOF")
for i := 1; i < r.Seuil; i++ {
r.Echec("h.example", coupure)
}
r.Reussite("h.example")
for i := 1; i < r.Seuil; i++ {
r.Echec("h.example", coupure)
}
if r.Has("h.example") {
t.Error("des incidents éloignés se sont additionnés")
}
}
// Une RAFALE est un seul incident. apt ouvre plusieurs connexions de front
// vers un dépôt et ferme celles dont il ne se sert pas : trois coupures dans
// la même seconde condamnaient l'hôte sans que rien n'ait rejeté notre
// certificat. Le client repartait alors sur un tunnel opaque, et son
// « apt-get update » y attendait jusqu'au délai — quatre heures.
func TestUneRafaleDeCoupuresNeCondamnePas(t *testing.T) {
r := NewRefusals(nil)
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
coupure := errors.New("read: connection reset by peer")
// Dix connexions fermées dans le même instant : un seul incident.
for i := 0; i < 10; i++ {
if r.Echec("deb.example", coupure) {
t.Fatalf("condamné sur la coupure %d d'une même rafale", i+1)
}
}
if r.Has("deb.example") {
t.Fatal("une rafale simultanée condamne l'hôte")
}
// Le temps passe : les coupures suivantes sont d'autres incidents, et le
// seuil finit par tomber.
for i := 1; i < r.Seuil; i++ {
horloge = horloge.Add(RafaleParDefaut + time.Second)
r.Echec("deb.example", coupure)
}
if !r.Has("deb.example") {
t.Error("des coupures espacées ne condamnent plus : un client qui" +
" rejette vraiment notre autorité ne passerait jamais en tunnel")
}
}
// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat.
// La rafale ne la retient pas : dix alertes d'affilée valent une décision.
func TestUneAlerteTrancheMemeEnRafale(t *testing.T) {
r := NewRefusals(nil)
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
if !r.Echec("epingleur.example", errRefus) {
t.Fatal("une alerte ne tranche pas dans le même instant qu'une autre")
}
if !r.Has("epingleur.example") {
t.Error("l'alerte n'est pas retenue")
}
}
// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat.
func TestUneAlerteCondamneDesLePremierEchec(t *testing.T) {
r := NewRefusals(nil)
if !r.Echec("epingleur.example", errors.New("remote error: tls: bad certificate")) {
t.Error("une alerte n'est pas retenue immédiatement")
}
}
// Un REFUS et une COUPURE ne disent pas la même chose.
//
// Le code les confondait : « connection reset by peer » était journalisé
// « certificat refusé » et faisait passer l'hôte en tunnel opaque. Un miroir
// de distribution se retrouve alors soustrait au cache sur une seule coupure,
// et tout son trafic repart à l'amont.
func TestUneCoupureNestPasUnRefus(t *testing.T) {
coupures := []error{
errors.New("read tcp 10.0.0.1:8899->10.0.0.2:33918: read:" +
" connection reset by peer"),
io.EOF,
errors.New("read tcp: i/o timeout"),
nil,
}
for _, err := range coupures {
if estRefusTLS(err) {
t.Errorf("%v pris pour un refus du client", err)
}
}
}
func TestUneAlerteEstUnRefus(t *testing.T) {
refus := []error{
errors.New("remote error: tls: bad certificate"),
errors.New("remote error: tls: unknown certificate authority"),
tls.AlertError(42),
}
for _, err := range refus {
if !estRefusTLS(err) {
t.Errorf("%v n'est pas reconnu comme un refus", err)
}
}
}
// L'erreur enveloppée compte autant : la bibliothèque en emballe parfois.
func TestUnRefusEnveloppeEstReconnu(t *testing.T) {
err := fmt.Errorf("poignée de main : %w", tls.AlertError(48))
if !estRefusTLS(err) {
t.Error("un refus enveloppé n'est pas reconnu")
}
}
// clientHelloBrut fabrique un ClientHello valide portant ce nom, sans rien
// engager : les octets sont capturés sur un tuyau, puis rejoués à la main.
func clientHelloBrut(t *testing.T, nom string) []byte {
t.Helper()
a, b := net.Pipe()
defer a.Close()
go func() {
tc := tls.Client(a, &tls.Config{ServerName: nom})
_ = tc.Handshake()
}()
b.SetReadDeadline(time.Now().Add(2 * time.Second))
brut, err := readFirstRecord(b)
b.Close()
if err != nil {
t.Fatalf("ClientHello : %v", err)
}
return brut
}
// Le contrôle précédent vérifie la RÈGLE ; celui-ci vérifie qu'elle est
// branchée. Sans lui, remettre les deux cas dans le même sac laisse les tests
// verts et recondamne un miroir au tunnel dès la première coupure.
func TestUneCoupureNeCondamnePasLHote(t *testing.T) {
ca, err := LoadOrCreateCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
refus := NewRefusals(nil)
front := &TLSFront{CA: ca, Refusals: refus, Proxy: proxyDeTest(t)}
hello := clientHelloBrut(t, "miroir.example")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
fini := make(chan struct{})
go func() {
if c, err := ln.Accept(); err == nil {
front.handle(c)
}
close(fini)
}()
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
// Le ClientHello part, puis la connexion est coupée BRUTALEMENT : c'est
// la coupure de transport que le code prenait pour un rejet d'autorité.
// Aucune alerte n'est émise — ce serait l'autre cas.
if _, err := c.Write(hello); err != nil {
t.Fatal(err)
}
c.(*net.TCPConn).SetLinger(0)
c.Close()
select {
case <-fini:
case <-time.After(3 * time.Second):
t.Fatal("la connexion n'a pas été traitée")
}
if refus.Has("miroir.example") {
t.Error("une coupure a condamné l'hôte au tunnel opaque")
}
}