erplibre/script/qemu_cache/rules_test.go
Mathieu Benoit a7706adf3d [ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
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
2026-09-14 16:46:15 -04:00

235 lines
7.6 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"strings"
"testing"
)
func jeuDeTest() RuleSet {
return RuleSet{
Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
}
}
// La garde qui compte : seul ce qui SORT du sous-réseau est détourné. Sans
// elle, une VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service sur
// la passerelle du /24 — part vers le cache, qui n'en sait rien faire.
func TestNftEpargneLeTraficInterne(t *testing.T) {
lignes := jeuDeTest().NftLines()
texte := strings.Join(lignes, "\n")
if !strings.Contains(texte, "ip daddr != 192.168.122.0/24") {
t.Errorf("l'exclusion du trafic interne manque :\n%s", texte)
}
for _, port := range []string{"tcp dport 80", "tcp dport 443"} {
if !strings.Contains(texte, port) {
t.Errorf("%q absent des règles", port)
}
}
if !strings.Contains(texte, "redirect to :8898") {
t.Error("le 80 ne part pas vers l'écoute HTTP")
}
if !strings.Contains(texte, "redirect to :8899") {
t.Error("le 443 ne part pas vers l'écoute TLS")
}
if !strings.Contains(texte, `iifname "virbr0"`) {
t.Error("les règles ne sont pas bornées au pont des VM")
}
}
// Chaque règle porte l'exclusion : une seule qui l'oublie suffit à couper le
// trafic interne du port qu'elle vise.
func TestChaqueRegleDeRedirectionPorteLExclusion(t *testing.T) {
for _, l := range jeuDeTest().NftLines() {
if !strings.Contains(l, "redirect to") {
continue
}
if !strings.Contains(l, "ip daddr != 192.168.122.0/24") {
t.Errorf("règle sans exclusion : %s", l)
}
}
for _, l := range jeuDeTest().IptablesLines() {
if !strings.Contains(l, "! -d 192.168.122.0/24") {
t.Errorf("règle iptables sans exclusion : %s", l)
}
}
}
// Le retrait est UN geste : un désarmement à moitié fait laisserait une VM
// détournée vers un cache éteint.
func TestRetraitEnUnGeste(t *testing.T) {
l := jeuDeTest().NftDeleteLine()
if !strings.Contains(l, "delete table ip "+TableName) {
t.Errorf("le retrait n'efface pas la table : %s", l)
}
if strings.Count(l, "\n") != 0 {
t.Errorf("le retrait tient sur plusieurs lignes : %q", l)
}
}
// La table porte un nom à nous : des règles ajoutées dans les chaînes
// d'autrui devraient être retrouvées une par une pour être retirées.
func TestTableNommee(t *testing.T) {
if !strings.Contains(jeuDeTest().NftLines()[0], TableName) {
t.Error("les règles ne vivent pas dans une table nommée")
}
}
// pip et npm ignorent le magasin système : poser l'autorité ne suffit pas
// pour eux, alors qu'elle suffit pour pacman et pour apt.
func TestVariablesDeLInvite(t *testing.T) {
lignes := GuestEnvLines("/etc/ssl/certs/ca-certificates.crt")
attendues := []string{"PIP_CERT=", "REQUESTS_CA_BUNDLE=", "NODE_EXTRA_CA_CERTS="}
if len(lignes) != len(attendues) {
t.Fatalf("%d variables, attendu %d", len(lignes), len(attendues))
}
texte := strings.Join(lignes, "\n")
for _, a := range attendues {
if !strings.Contains(texte, a) {
t.Errorf("%q manque :\n%s", a, texte)
}
}
for _, l := range lignes {
if !strings.HasSuffix(l, "/etc/ssl/certs/ca-certificates.crt") {
t.Errorf("la variable ne pointe pas l'autorité : %s", l)
}
}
}
// Le chemin ET la commande changent par famille : se tromper laisse une VM
// qui échoue sur chaque téléchargement HTTPS.
func TestCommandeDeConfiance(t *testing.T) {
cas := map[string][3]string{
"pacman": {
"/etc/ca-certificates/trust-source/anchors", "trust extract-compat",
"/etc/ssl/certs/ca-certificates.crt",
},
"apt": {
"/usr/local/share/ca-certificates", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
},
"dnf": {
"/etc/pki/ca-trust/source/anchors", "update-ca-trust",
"/etc/pki/tls/certs/ca-bundle.crt",
},
"zypper": {
"/etc/pki/trust/anchors", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
},
}
for famille, attendu := range cas {
dir, cmd, bundle, ok := GuestTrustCommand(famille)
if !ok {
t.Errorf("%s : famille inconnue", famille)
continue
}
if dir != attendu[0] {
t.Errorf("%s : répertoire %q, attendu %q", famille, dir, attendu[0])
}
if cmd != attendu[1] {
t.Errorf("%s : commande %q, attendu %q", famille, cmd, attendu[1])
}
if bundle != attendu[2] {
t.Errorf("%s : faisceau %q, attendu %q", famille, bundle, attendu[2])
}
}
if _, _, _, ok := GuestTrustCommand("plan9"); ok {
t.Error("une famille inconnue est pourtant acceptée")
}
}
// L'exception doit passer AVANT les détournements : nftables rend le verdict
// de la première règle qui correspond, et une exception placée après ne
// serait jamais atteinte.
func TestExceptionAvantDetournement(t *testing.T) {
r := RuleSet{
Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc"},
}
lignes := r.NftLines()
iException, iRedirect := -1, -1
for i, l := range lignes {
if strings.Contains(l, "ether saddr @"+BypassSetName) {
iException = i
}
if iRedirect < 0 && strings.Contains(l, "redirect to :") {
iRedirect = i
}
}
if iException < 0 {
t.Fatal("aucune règle d'exception rendue")
}
if iRedirect < 0 {
t.Fatal("aucun détournement rendu")
}
if iException > iRedirect {
t.Errorf("l'exception (ligne %d) suit le détournement (ligne %d)",
iException, iRedirect)
}
}
// L'ensemble existe même vide : une règle qui viserait un ensemble absent
// ferait échouer le jeu ENTIER, donc le démarrage du service — un cache muet
// là où l'on n'a jamais demandé d'exception.
func TestEnsembleRenduMemeVide(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899}
texte := strings.Join(r.NftLines(), "\n")
if !strings.Contains(texte, "set "+BypassSetName+" {") {
t.Error("l'ensemble n'est pas déclaré quand aucune MAC n'est exceptée")
}
if !strings.Contains(texte, "type ether_addr") {
t.Error("l'ensemble n'a pas de type")
}
if strings.Contains(texte, "elements =") {
t.Error("un ensemble vide ne doit pas déclarer d'éléments")
}
}
func TestElementsRendusQuandIlYEnA(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc", "52:54:00:11:22:33"}}
texte := strings.Join(r.NftLines(), "\n")
if !strings.Contains(texte,
"elements = { 52:54:00:aa:bb:cc, 52:54:00:11:22:33 }") {
t.Errorf("éléments mal rendus :\n%s", texte)
}
}
// iptables n'a pas d'ensemble nommé : la chaîne se bâtit par « -A »
// successifs, et un RETURN posé après les redirections ne serait jamais
// atteint.
func TestIptablesExceptionEnTete(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899,
Bypass: []string{"52:54:00:aa:bb:cc"}}
lignes := r.IptablesLines()
if len(lignes) != 3 {
t.Fatalf("%d lignes rendues, trois attendues : %v", len(lignes), lignes)
}
if !strings.Contains(lignes[0], "--mac-source 52:54:00:aa:bb:cc") ||
!strings.Contains(lignes[0], "-j RETURN") {
t.Errorf("la première ligne n'est pas l'exception : %q", lignes[0])
}
for _, l := range lignes[1:] {
if !strings.Contains(l, "REDIRECT") {
t.Errorf("ligne inattendue : %q", l)
}
}
}
// Sans exception, le jeu iptables est celui d'avant : trois lignes là où deux
// suffisent trahirait une ligne vide rendue pour rien.
func TestIptablesSansExceptionInchange(t *testing.T) {
r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24",
HTTPPort: 8898, TLSPort: 8899}
if n := len(r.IptablesLines()); n != 2 {
t.Errorf("%d lignes sans exception, deux attendues", n)
}
}