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
159 lines
6.3 KiB
Go
159 lines
6.3 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// RuleSet décrit le détournement à poser sur le pont de l'orchestrateur.
|
|
//
|
|
// Les règles sont RENDUES sous forme de texte et jamais exécutées depuis ce
|
|
// paquet : le script d'installation les applique, et un test peut donc les
|
|
// vérifier au caractère près sans toucher au pare-feu de la machine qui
|
|
// exécute les tests.
|
|
type RuleSet struct {
|
|
// Bridge est le pont que libvirt donne à ses VM, « virbr0 » par défaut.
|
|
Bridge string
|
|
// Subnet est le /24 que ce réseau sert.
|
|
Subnet string
|
|
// HTTPPort et TLSPort sont les deux écoutes du cache.
|
|
HTTPPort int
|
|
TLSPort int
|
|
// Bypass : les adresses MAC soustraites au détournement, déjà
|
|
// canoniques. Vide, l'ensemble existe quand même — il est peuplé à
|
|
// chaud, et une règle qui viserait un ensemble absent ferait échouer
|
|
// TOUT le jeu de règles, donc le démarrage du service.
|
|
Bypass []string
|
|
}
|
|
|
|
// TableName : une table à nous, effaçable d'un geste, plutôt que des règles
|
|
// ajoutées dans les chaînes d'autrui — que le retrait devrait alors retrouver
|
|
// une par une.
|
|
const TableName = "erplibre_qemu_cache"
|
|
|
|
// NftLines rend les lignes à passer à « nft -f - ».
|
|
//
|
|
// La clause « ip daddr != <subnet> » est la garde qui compte : sans elle, une
|
|
// VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service quelconque
|
|
// sur la passerelle du /24 — se retrouve détournée vers le cache, qui ne sait
|
|
// rien en faire. Seul ce qui SORT du sous-réseau est intercepté. Une règle
|
|
// réseau posée trop large sur le pont d'un hôte est la classe de panne qui
|
|
// prive une machine de son propre réseau.
|
|
//
|
|
// L'exception par MAC passe AVANT les deux détournements, sans quoi elle
|
|
// n'aurait aucun effet : la première règle qui rend un verdict l'emporte.
|
|
// « accept » clôt cette chaîne-ci et non le hook : les chaînes des autres
|
|
// tables — le masquage de libvirt, notamment — sont évaluées ensuite, donc
|
|
// une VM exceptée garde son réseau normal.
|
|
func (r RuleSet) NftLines() []string {
|
|
lignes := []string{
|
|
fmt.Sprintf("table ip %s {", TableName),
|
|
fmt.Sprintf(" set %s {", BypassSetName),
|
|
" type ether_addr",
|
|
}
|
|
if len(r.Bypass) > 0 {
|
|
lignes = append(lignes, fmt.Sprintf(
|
|
" elements = { %s }", strings.Join(r.Bypass, ", ")))
|
|
}
|
|
lignes = append(lignes,
|
|
" }",
|
|
" chain prerouting {",
|
|
" type nat hook prerouting priority dstnat; policy accept;",
|
|
fmt.Sprintf(
|
|
` iifname "%s" ether saddr @%s accept`,
|
|
r.Bridge, BypassSetName),
|
|
fmt.Sprintf(
|
|
` iifname "%s" ip saddr %s ip daddr != %s tcp dport 80 redirect to :%d`,
|
|
r.Bridge, r.Subnet, r.Subnet, r.HTTPPort),
|
|
fmt.Sprintf(
|
|
` iifname "%s" ip saddr %s ip daddr != %s tcp dport 443 redirect to :%d`,
|
|
r.Bridge, r.Subnet, r.Subnet, r.TLSPort),
|
|
" }",
|
|
"}",
|
|
)
|
|
return lignes
|
|
}
|
|
|
|
// NftDeleteLine rend le geste de retrait. Une seule table à effacer, donc un
|
|
// seul geste : le désarmement ne peut pas être à moitié fait.
|
|
func (r RuleSet) NftDeleteLine() string {
|
|
return fmt.Sprintf("nft delete table ip %s", TableName)
|
|
}
|
|
|
|
// IptablesLines rend l'équivalent pour un hôte qui n'a pas nft. Le script
|
|
// d'installation choisit selon ce qui répond ; les deux jeux disent la même
|
|
// chose.
|
|
//
|
|
// Les exceptions sont émises en tête : iptables n'a pas d'ensemble nommé, la
|
|
// chaîne est bâtie par « -A » successifs, et un RETURN posé après les
|
|
// redirections ne serait jamais atteint. Le corollaire est qu'ici une
|
|
// exception neuve demande de reposer les règles, là où nft en ajoute une à
|
|
// chaud.
|
|
func (r RuleSet) IptablesLines() []string {
|
|
var lignes []string
|
|
for _, mac := range r.Bypass {
|
|
lignes = append(lignes, fmt.Sprintf(
|
|
"iptables -t nat -A PREROUTING -i %s -m mac --mac-source %s "+
|
|
"-j RETURN", r.Bridge, mac))
|
|
}
|
|
return append(lignes,
|
|
fmt.Sprintf(
|
|
"iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+
|
|
"-p tcp --dport 80 -j REDIRECT --to-ports %d",
|
|
r.Bridge, r.Subnet, r.Subnet, r.HTTPPort),
|
|
fmt.Sprintf(
|
|
"iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+
|
|
"-p tcp --dport 443 -j REDIRECT --to-ports %d",
|
|
r.Bridge, r.Subnet, r.Subnet, r.TLSPort),
|
|
)
|
|
}
|
|
|
|
// GuestEnvLines rend ce qu'une VM doit poser en plus de l'autorité.
|
|
//
|
|
// pip embarque son propre jeu de certificats et IGNORE le magasin système ;
|
|
// npm fait de même. Poser l'autorité ne suffit donc pas pour eux, alors
|
|
// qu'elle suffit pour pacman et pour apt, qui lisent le magasin.
|
|
//
|
|
// La valeur visée est le FAISCEAU système, que la commande de confiance
|
|
// régénère avec notre autorité dedans — et non le seul certificat du cache :
|
|
// pointer celui-là ferait perdre à pip toutes les autres autorités, donc
|
|
// échouer sur le premier hôte que le cache ne déchiffre pas, et le jour où le
|
|
// cache disparaît.
|
|
func GuestEnvLines(caPath string) []string {
|
|
return []string{
|
|
fmt.Sprintf("PIP_CERT=%s", caPath),
|
|
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caPath),
|
|
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caPath),
|
|
}
|
|
}
|
|
|
|
// GuestTrustCommand rend la commande qui fait approuver l'autorité, par
|
|
// famille de distribution. Le chemin ET la commande changent, et se tromper
|
|
// laisse une VM qui échoue sur chaque téléchargement HTTPS.
|
|
//
|
|
// Les familles portent le nom de leur gestionnaire de paquets, comme partout
|
|
// ailleurs dans le dépôt. La même table existe côté déploiement, qui écrit le
|
|
// fichier dans l'invité ; un test les compare, la dérive entre deux copies
|
|
// étant le seul risque de cette duplication.
|
|
func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) {
|
|
switch family {
|
|
case "pacman":
|
|
return "/etc/ca-certificates/trust-source/anchors", "trust extract-compat",
|
|
"/etc/ssl/certs/ca-certificates.crt", true
|
|
case "apt":
|
|
return "/usr/local/share/ca-certificates", "update-ca-certificates",
|
|
"/etc/ssl/certs/ca-certificates.crt", true
|
|
case "dnf":
|
|
return "/etc/pki/ca-trust/source/anchors", "update-ca-trust",
|
|
"/etc/pki/tls/certs/ca-bundle.crt", true
|
|
case "zypper":
|
|
// openSUSE range ses ancres ailleurs que la famille RHEL, tout en
|
|
// employant la même commande que Debian.
|
|
return "/etc/pki/trust/anchors", "update-ca-certificates",
|
|
"/etc/ssl/certs/ca-certificates.crt", true
|
|
}
|
|
return "", "", "", false
|
|
}
|