// © 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) } }