diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index aa1ad50..bd4a686 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -134,6 +134,7 @@ var anglais = map[string]string{ "tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)", "tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener", "tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead", + "tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest", "tunnel vers %s : %v": "tunnel to %s: %v", "ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)", "longueur d'enregistrement invraisemblable : %d": "implausible record length: %d", diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index cc51234..f5e86ae 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.11" +const version = "0.2.12" func main() { var ( diff --git a/script/qemu_cache/mitm.go b/script/qemu_cache/mitm.go index 92be2de..f919f23 100644 --- a/script/qemu_cache/mitm.go +++ b/script/qemu_cache/mitm.go @@ -26,6 +26,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" ) @@ -233,8 +234,27 @@ type Refusals struct { // Seuil : combien de poignées de main de suite doivent échouer avant de // conclure. Nul, la valeur par défaut s'applique. Seuil int + // Rafale : deux coupures plus rapprochées que cela ne comptent que pour + // une. Nulle, la valeur par défaut s'applique ; négative, chaque coupure + // compte, ce dont un test se sert pour condamner sans attendre. + Rafale time.Duration + // Maintenant rend l'instant courant. Nulle, time.Now : un test injecte + // son horloge pour espacer des coupures sans dormir. + Maintenant func() time.Time + // dernier : quand a été COMPTÉE la dernière coupure d'un hôte, ce qui + // distingue une rafale d'un échec qui se répète. + dernier map[string]time.Time } +// RafaleParDefaut : en deçà, deux poignées de main coupées sont le MÊME +// 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 atteignaient le +// seuil sans que rien n'ait rejeté notre certificat. Un client qui refuse +// vraiment réessaie et échoue encore, à des secondes de là. +const RafaleParDefaut = 2 * time.Second + // SeuilParDefaut : trois échecs de suite avant de renoncer à déchiffrer. // // Un client qui ne nous fera jamais confiance échoue à CHAQUE fois ; une @@ -269,8 +289,10 @@ func NewRefusals(static []string) *Refusals { declares: map[string]bool{}, apprises: map[string]refus{}, echecs: map[string]int{}, + dernier: map[string]time.Time{}, Oubli: OubliParDefaut, Seuil: SeuilParDefaut, + Rafale: RafaleParDefaut, } for _, h := range static { if h = strings.TrimSpace(strings.ToLower(h)); h != "" { @@ -309,7 +331,7 @@ func (r *Refusals) Has(host string) bool { if vu.alerte { return true } - if r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + if r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli { r.oublier(host) return false } @@ -322,6 +344,7 @@ func (r *Refusals) Has(host string) bool { func (r *Refusals) oublier(host string) { delete(r.apprises, host) delete(r.echecs, host) + delete(r.dernier, host) } // Echec note une poignée de main manquée et dit si l'hôte passe en tunnel. @@ -343,6 +366,18 @@ func (r *Refusals) Echec(host string, raison error) bool { seuil = SeuilParDefaut } alerte := estRefusTLS(raison) + maintenant := r.maintenant() + // Une rafale ne compte qu'une fois. Sans cela, un client qui ouvre + // plusieurs connexions de front et ferme les inutiles atteint le seuil + // seul : l'hôte passe en tunnel, le magasin cesse de le servir, et le + // client qui attend sur ce tunnel n'a plus rien pour s'en sortir. + if !alerte { + if precedent, vu := r.dernier[host]; vu && + maintenant.Sub(precedent) < r.rafale() { + return false + } + r.dernier[host] = maintenant + } r.echecs[host]++ if !alerte && r.echecs[host] < seuil { return false @@ -354,10 +389,27 @@ func (r *Refusals) Echec(host string, raison error) bool { // La NATURE est retenue avec l'instant : elle décide si ce refus se // rouvrira. Un seuil atteint sur des coupures reste un soupçon, même // répété trois fois. - r.apprises[host] = refus{quand: time.Now(), alerte: alerte} + r.apprises[host] = refus{quand: maintenant, alerte: alerte} return true } +// maintenant rend l'instant courant, celui de l'horloge injectée s'il y en a. +func (r *Refusals) maintenant() time.Time { + if r.Maintenant != nil { + return r.Maintenant() + } + return time.Now() +} + +// rafale rend la distance en deçà de laquelle deux coupures n'en font qu'une. +// Négative, elle les fait toutes compter. +func (r *Refusals) rafale() time.Duration { + if r.Rafale != 0 { + return r.Rafale + } + return RafaleParDefaut +} + // Reussite efface le compte d'un hôte : la coupure d'avant n'était qu'un // incident, et deux incidents éloignés ne doivent pas s'additionner jusqu'au // seuil. @@ -365,6 +417,7 @@ func (r *Refusals) Reussite(host string) { r.mu.Lock() defer r.mu.Unlock() delete(r.echecs, host) + delete(r.dernier, host) } func (r *Refusals) List() []string { @@ -375,7 +428,7 @@ func (r *Refusals) List() []string { out = append(out, h) } for h, vu := range r.apprises { - if !vu.alerte && r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + if !vu.alerte && r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli { r.oublier(h) continue } @@ -596,10 +649,28 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool { Client: clientDe(c.RemoteAddr().String()), }) + // Un tunnel qui se referme ne laissait AUCUNE trace : le client qui + // attendait dessus paraissait bloqué sans cause, et rien au journal ne + // disait lequel des deux bouts s'était taire. La durée et les octets par + // direction le disent — un tunnel qui meurt sans avoir rien rendu à + // l'invité est l'empreinte d'un amont qui coupe. + debut := time.Now() + var versAmont, versClient atomic.Int64 done := make(chan struct{}, 2) - go func() { io.Copy(up, c); done <- struct{}{} }() - go func() { io.Copy(c, up); done <- struct{}{} }() + go func() { + n, _ := io.Copy(up, c) + versAmont.Store(n) + done <- struct{}{} + }() + go func() { + n, _ := io.Copy(c, up) + versClient.Store(n) + done <- struct{}{} + }() <-done + log.Printf(T("tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité"), + dst, time.Since(debut).Round(time.Second), + HumanBytes(versAmont.Load()), HumanBytes(versClient.Load())) return false } diff --git a/script/qemu_cache/mitm_test.go b/script/qemu_cache/mitm_test.go index 1675ef2..1497d4f 100644 --- a/script/qemu_cache/mitm_test.go +++ b/script/qemu_cache/mitm_test.go @@ -210,6 +210,7 @@ func TestPremierEnregistrementNonTLS(t *testing.T) { // 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) { @@ -223,9 +224,14 @@ func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) { 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" + @@ -244,6 +250,7 @@ func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) { 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) @@ -275,6 +282,7 @@ func TestLOubliRemetLeCompteAZero(t *testing.T) { 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) } @@ -307,6 +315,7 @@ func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) { const hote = "registre.example.invalid" r := NewRefusals(nil) + r.Rafale = -1 for i := 0; i < r.Seuil; i++ { r.Echec(hote, io.EOF) } @@ -367,6 +376,7 @@ func TestLOubliEstActifParDefaut(t *testing.T) { // 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) @@ -380,6 +390,53 @@ func TestUneReussiteEffaceLeCompte(t *testing.T) { } } +// 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)