VMs of one host pulled the same packages again and again, and an install could not be proven to work without the network. A Go service intercepts the bridge transparently: a package file is served from disk, an index is always revalidated upstream and replayed only when upstream is mute, git is mirrored. Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut the network for a whole install. The TODO menu installs, diagnoses, fills, cleans and copies the store; long_test/qemu_cache.py measures that a second VM pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py. --- FR --- Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait qu'une installation marche sans réseau. Un service Go intercepte le pont de façon transparente : un fichier de paquet est servi du disque, un index est toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git est mis en miroir. Le déploiement pose l'autorité du cache dans les invités QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ; long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py. Assisted-by: Claude Opus 5
236 lines
7 KiB
Go
236 lines
7 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// portDeLAmont rend le port d'un amont de test, sous ses deux formes.
|
|
func portDeLAmont(t *testing.T, a *amontScripte) (int, string) {
|
|
t.Helper()
|
|
_, brut, err := net.SplitHostPort(a.hote())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := strconv.Atoi(brut)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n, brut
|
|
}
|
|
|
|
// Une requête qui vise le cache lui-même est refusée sans être relayée : la
|
|
// relayer la renverrait vers cette même écoute, qui la relaierait encore.
|
|
//
|
|
// L'amont de test JOUE le cache : son port est déclaré comme écoute. Un seul
|
|
// appel reçu prouve que la requête est partie.
|
|
func TestUneRequeteAuCacheLuiMemeEstRefusee(t *testing.T) {
|
|
soi := nouvelAmontScripte(t, servir("ne doit jamais répondre"))
|
|
port, brut := portDeLAmont(t, soi)
|
|
p := proxyDeTest(t)
|
|
journal := journalDeTest(t, p)
|
|
p.Ecoutes = []int{port}
|
|
|
|
for _, hote := range []string{"127.0.0.1:" + brut, "localhost:" + brut} {
|
|
w := joue(t, p, "GET", hote, "/boucle")
|
|
if w.Code != http.StatusLoopDetected {
|
|
t.Fatalf("%s : code %d, attendu 508", hote, w.Code)
|
|
}
|
|
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeError {
|
|
t.Errorf("%s : servi « %s », attendu « %s »", hote, got, OutcomeError)
|
|
}
|
|
for _, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") {
|
|
if !strings.HasPrefix(l, "#") {
|
|
t.Errorf("ligne exécutable par un shell : %q", l)
|
|
}
|
|
}
|
|
}
|
|
if n := soi.appels(); n != 0 {
|
|
t.Errorf("la requête est partie %d fois vers le cache lui-même", n)
|
|
}
|
|
for _, l := range lignesDuJournal(t, journal) {
|
|
if l.Outcome != OutcomeError || l.Status != http.StatusLoopDetected {
|
|
t.Errorf("journal : %s %d, attendu error 508", l.Outcome, l.Status)
|
|
}
|
|
}
|
|
|
|
// Un autre port de la même machine reste un amont comme un autre.
|
|
autre := nouvelAmontScripte(t, servir("amont légitime"))
|
|
if w := joue(t, p, "GET", autre.hote(), "/fichier"); w.Code != http.StatusOK {
|
|
t.Errorf("un amont local sur un autre port est refusé : %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// Une redirection qui pointe vers le cache n'est pas suivie par lui.
|
|
func TestUneRedirectionVersLeCacheNEstPasSuivie(t *testing.T) {
|
|
soi := nouvelAmontScripte(t, servir("ne doit jamais répondre"))
|
|
port, brut := portDeLAmont(t, soi)
|
|
pub := nouvelAmontScripte(t, rediriger(http.StatusFound,
|
|
"http://127.0.0.1:"+brut+"/outil_1.0_amd64.deb"))
|
|
p := proxyDeTest(t)
|
|
p.Ecoutes = []int{port}
|
|
|
|
w := joue(t, p, "GET", pub.hote(), "/outil_1.0_amd64.deb")
|
|
if w.Code != http.StatusFound {
|
|
t.Errorf("code %d, attendu la redirection rendue au client", w.Code)
|
|
}
|
|
if n := soi.appels(); n != 0 {
|
|
t.Errorf("la redirection a été suivie %d fois vers le cache", n)
|
|
}
|
|
}
|
|
|
|
func TestViseLeCache(t *testing.T) {
|
|
p := &Proxy{Ecoutes: []int{8898, 8899}}
|
|
cas := map[string]bool{
|
|
"http://127.0.0.1:8898/x": true,
|
|
"http://localhost:8898/x": true,
|
|
"https://LOCALHOST.:8899/x": true,
|
|
"http://[::1]:8899/x": true,
|
|
"http://0.0.0.0:8898/x": true,
|
|
"http://127.0.0.1:8897/x": false,
|
|
"http://192.0.2.10:8898/x": false,
|
|
"https://miroir.example/x.deb": false,
|
|
}
|
|
for brut, attendu := range cas {
|
|
u, _ := url.Parse(brut)
|
|
if got := p.viseLeCache(u); got != attendu {
|
|
t.Errorf("%s : %v, attendu %v", brut, got, attendu)
|
|
}
|
|
}
|
|
// Une adresse d'une interface de CETTE machine joint aussi l'écoute.
|
|
if adresses, err := net.InterfaceAddrs(); err == nil {
|
|
for _, a := range adresses {
|
|
n, ok := a.(*net.IPNet)
|
|
if !ok || n.IP.IsLoopback() || n.IP.To4() == nil {
|
|
continue
|
|
}
|
|
u, _ := url.Parse("http://" + n.IP.String() + ":8898/x")
|
|
if !p.viseLeCache(u) {
|
|
t.Errorf("%s, adresse de cette machine, n'est pas reconnue", u)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
// Sans écoute déclarée, rien n'est refusé.
|
|
u, _ := url.Parse("http://127.0.0.1:8898/x")
|
|
if (&Proxy{}).viseLeCache(u) {
|
|
t.Error("un proxy sans écoute déclarée refuse une requête")
|
|
}
|
|
}
|
|
|
|
func TestMemeAdresse(t *testing.T) {
|
|
cas := []struct {
|
|
a, b string
|
|
attendu bool
|
|
}{
|
|
{"127.0.0.1:8899", "127.0.0.1:8899", true},
|
|
{"127.0.0.1:8899", "[::ffff:127.0.0.1]:8899", true},
|
|
{"127.0.0.1:8899", "127.0.0.1:8898", false},
|
|
{"192.0.2.1:443", "127.0.0.1:443", false},
|
|
{"pas une adresse", "127.0.0.1:443", false},
|
|
}
|
|
for _, c := range cas {
|
|
if got := memeAdresse(c.a, c.b); got != c.attendu {
|
|
t.Errorf("%s / %s : %v, attendu %v", c.a, c.b, got, c.attendu)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ecouteComptee compte dans Accept et rend la socket TCP BRUTE : une
|
|
// connexion enveloppée ferait échouer originalDst avant le tunnel, et le test
|
|
// ne prouverait plus rien. Au plafond, elle se ferme : une boucle doit rester
|
|
// bornée, pour que l'échec du test ne fasse pas tomber la machine qui le
|
|
// lance.
|
|
type ecouteComptee struct {
|
|
*net.TCPListener
|
|
acceptees int64
|
|
plafond int64
|
|
}
|
|
|
|
func (l *ecouteComptee) Accept() (net.Conn, error) {
|
|
c, err := l.AcceptTCP()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if atomic.AddInt64(&l.acceptees, 1) >= l.plafond {
|
|
l.TCPListener.Close()
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// sondeOriginalDst saute le test là où le noyau ne rend pas, pour une
|
|
// connexion locale non détournée, l'écoute elle-même comme destination
|
|
// d'origine : sans suivi de connexion, originalDst échoue avant le tunnel et
|
|
// la garde n'a rien à garder.
|
|
func sondeOriginalDst(t *testing.T) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
client, err := net.Dial("tcp", ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close()
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
dst, err := originalDst(c)
|
|
if err != nil || !memeAdresse(dst, ln.Addr().String()) {
|
|
t.Skipf("destination d'origine indisponible ici (%q, %v)", dst, err)
|
|
}
|
|
}
|
|
|
|
// Une connexion non détournée vers le front TLS, pour un hôte en tunnel, a
|
|
// pour destination d'origine l'écoute elle-même. Le tunnel la rappelait :
|
|
// chaque tour acceptait une connexion de plus.
|
|
func TestUnTunnelNeSeRappellePasLuiMeme(t *testing.T) {
|
|
sondeOriginalDst(t)
|
|
brut, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln := &ecouteComptee{TCPListener: brut.(*net.TCPListener), plafond: 16}
|
|
t.Cleanup(func() { ln.Close() })
|
|
|
|
ca, err := LoadOrCreateCA(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const hote = "tunnel.example.invalid"
|
|
front := &TLSFront{
|
|
CA: ca, Proxy: proxyDeTest(t), Refusals: NewRefusals([]string{hote}),
|
|
}
|
|
go front.Serve(ln)
|
|
|
|
c, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
if _, err := c.Write(clientHelloBrut(t, hote)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
if _, err := io.ReadAll(c); err != nil {
|
|
t.Errorf("la connexion n'est pas refermée : %v", err)
|
|
}
|
|
if n := atomic.LoadInt64(&ln.acceptees); n != 1 {
|
|
t.Errorf("%d connexions acceptées pour une seule demande :"+
|
|
" le tunnel s'est rappelé lui-même", n)
|
|
}
|
|
}
|