Le binaire ne parlait que français alors que le menu suit EL_LANG : un opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message humain passe par T(), dont la clé EST le texte français, et un catalogue anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses motifs ne connaissant que cette langue. Un test refuse un message sans traduction, une traduction orpheline et un verbe de format qui diverge. --- EN --- The binary spoke French only while the menu follows EL_LANG: an English reader got a status he could not read. Every human message goes through T(), whose key IS the French text, and an English catalogue translates it; the language comes from --lang, then EL_LANG, French remaining the default. The menu asks for French for what it READS BACK, its patterns knowing that language alone. A test refuses a message without translation, an orphan translation, and a format verb that drifts. Assisted-by: Claude Opus 5
253 lines
7.8 KiB
Go
253 lines
7.8 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Le détournement est TRANSPARENT : il vaut pour tout ce qui sort du pont, et
|
|
// une VM n'a aucun moyen de s'y soustraire depuis l'intérieur. Ne pas lui
|
|
// donner l'autorité ne la dispense pas d'être interceptée — cela lui fait
|
|
// seulement refuser un certificat qu'elle ne reconnaît pas, et échouer sur
|
|
// « self-signed certificate in certificate chain ».
|
|
//
|
|
// Une exception doit donc être posée sur l'HÔTE, avant que la VM démarre, et
|
|
// elle a besoin d'un identifiant que la VM porte de façon stable. L'adresse
|
|
// IP ne convient pas : elle vient d'un bail qui change. L'adresse MAC est
|
|
// fixée dans la définition du domaine et ne bouge plus.
|
|
//
|
|
// Elle est tenue dans un ensemble nommé nftables plutôt que dans les règles
|
|
// elles-mêmes : un élément s'ajoute et se retire à chaud, sans reposer le
|
|
// jeu de règles, donc sans couper les téléchargements des autres VM.
|
|
|
|
// BypassSetName : le nom de l'ensemble à l'intérieur de la table.
|
|
const BypassSetName = "bypass"
|
|
|
|
// BypassEntry associe l'adresse MAC soustraite au nom de la VM qui la porte.
|
|
//
|
|
// Le nom n'entre dans aucune règle ; il existe pour que la liste soit
|
|
// relisible et pour que l'on sache quelle entrée retirer quand la VM meurt.
|
|
// Une MAC orpheline est le mode de défaillance de ce fichier : réattribuée à
|
|
// une VM neuve, elle la soustrairait au cache sans que personne l'ait
|
|
// demandé, et rien ne le dirait.
|
|
type BypassEntry struct {
|
|
MAC string
|
|
Name string
|
|
}
|
|
|
|
// NormalizeMAC rend la forme canonique — six octets, minuscules,
|
|
// deux-points — ou dit pourquoi l'entrée ne peut pas en être une.
|
|
//
|
|
// Deux refus qui ne sont pas des caprices. L'adresse nulle correspond à tout
|
|
// ce qui n'a pas d'adresse et n'identifie donc rien. Une adresse de diffusion
|
|
// ou de groupe — bit de poids faible du premier octet à 1 — ne peut JAMAIS
|
|
// apparaître comme adresse SOURCE : l'entrée serait morte à l'écriture, et
|
|
// une exception qui ne s'applique jamais est pire qu'absente, puisqu'on la
|
|
// croit posée.
|
|
func NormalizeMAC(s string) (string, error) {
|
|
brut := strings.TrimSpace(s)
|
|
if brut == "" {
|
|
return "", fmt.Errorf("%s", T("adresse MAC vide"))
|
|
}
|
|
// Douze caractères hexadécimaux sans séparateur : la forme que rendent
|
|
// plusieurs outils, que net.ParseMAC ne lit pas.
|
|
if len(brut) == 12 && !strings.ContainsAny(brut, ":-.") {
|
|
var parts []string
|
|
for i := 0; i < 12; i += 2 {
|
|
parts = append(parts, brut[i:i+2])
|
|
}
|
|
brut = strings.Join(parts, ":")
|
|
}
|
|
adr, err := net.ParseMAC(brut)
|
|
if err != nil {
|
|
return "", fmt.Errorf(T("adresse MAC illisible %q"), s)
|
|
}
|
|
if len(adr) != 6 {
|
|
return "", fmt.Errorf(T("adresse MAC de %d octets, six attendus"), len(adr))
|
|
}
|
|
if adr[0]&1 == 1 {
|
|
return "", fmt.Errorf(
|
|
T("%s est une adresse de groupe : jamais une adresse source"), adr)
|
|
}
|
|
nulle := true
|
|
for _, o := range adr {
|
|
if o != 0 {
|
|
nulle = false
|
|
break
|
|
}
|
|
}
|
|
if nulle {
|
|
return "", fmt.Errorf("%s", T("l'adresse nulle n'identifie aucune machine"))
|
|
}
|
|
return strings.ToLower(adr.String()), nil
|
|
}
|
|
|
|
// BypassFile est la liste DURABLE. L'ensemble nftables vit dans le noyau et
|
|
// disparaît avec la table ; ce fichier est ce que le service relit à chaque
|
|
// démarrage pour reposer les mêmes exceptions.
|
|
type BypassFile struct {
|
|
Path string
|
|
}
|
|
|
|
// Load rend les entrées, triées par MAC. Un fichier absent n'est pas une
|
|
// erreur : c'est l'état d'une installation où personne n'a rien excepté.
|
|
//
|
|
// Une ligne illisible est SAUTÉE et non fatale. Le fichier est éditable à la
|
|
// main, et refuser de démarrer le service pour une faute de frappe dans une
|
|
// exception coûterait plus que d'ignorer la ligne.
|
|
func (b BypassFile) Load() ([]BypassEntry, error) {
|
|
fh, err := os.Open(b.Path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer fh.Close()
|
|
|
|
vues := map[string]bool{}
|
|
var out []BypassEntry
|
|
sc := bufio.NewScanner(fh)
|
|
for sc.Scan() {
|
|
ligne := strings.TrimSpace(sc.Text())
|
|
if i := strings.IndexByte(ligne, '#'); i >= 0 {
|
|
ligne = strings.TrimSpace(ligne[:i])
|
|
}
|
|
if ligne == "" {
|
|
continue
|
|
}
|
|
champs := strings.Fields(ligne)
|
|
mac, err := NormalizeMAC(champs[0])
|
|
if err != nil || vues[mac] {
|
|
continue
|
|
}
|
|
vues[mac] = true
|
|
nom := ""
|
|
if len(champs) > 1 {
|
|
nom = strings.Join(champs[1:], " ")
|
|
}
|
|
out = append(out, BypassEntry{MAC: mac, Name: nom})
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].MAC < out[j].MAC })
|
|
return out, nil
|
|
}
|
|
|
|
// Save réécrit le fichier en entier, par un remplacement atomique : une
|
|
// écriture coupée en deux laisserait une liste tronquée que le prochain
|
|
// démarrage prendrait pour la vérité.
|
|
func (b BypassFile) Save(entries []BypassEntry) error {
|
|
if err := os.MkdirAll(filepath.Dir(b.Path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString("# Exceptions du cache de téléchargement des VM QEMU.\n")
|
|
sb.WriteString("# Une ligne « <MAC> <nom de la VM> » par machine que le\n")
|
|
sb.WriteString("# détournement doit ignorer. Relu à chaque démarrage du\n")
|
|
sb.WriteString("# service ; une MAC dont la VM n'existe plus est à retirer.\n")
|
|
for _, e := range entries {
|
|
if e.Name == "" {
|
|
sb.WriteString(e.MAC + "\n")
|
|
continue
|
|
}
|
|
fmt.Fprintf(&sb, "%s %s\n", e.MAC, e.Name)
|
|
}
|
|
tmp := b.Path + ".part"
|
|
if err := os.WriteFile(tmp, []byte(sb.String()), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, b.Path)
|
|
}
|
|
|
|
// Add pose une exception et rend la MAC canonique. Reposer une MAC déjà
|
|
// présente met son nom à jour au lieu de créer un doublon : le déploiement
|
|
// est relancé, et deux lignes pour une machine rendraient le retrait
|
|
// incomplet.
|
|
func (b BypassFile) Add(mac, nom string) (string, error) {
|
|
canon, err := NormalizeMAC(mac)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
entries, err := b.Load()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
remplacee := false
|
|
for i := range entries {
|
|
if entries[i].MAC == canon {
|
|
entries[i].Name = nom
|
|
remplacee = true
|
|
break
|
|
}
|
|
}
|
|
if !remplacee {
|
|
entries = append(entries, BypassEntry{MAC: canon, Name: nom})
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].MAC < entries[j].MAC
|
|
})
|
|
}
|
|
return canon, b.Save(entries)
|
|
}
|
|
|
|
// Del retire une exception. Rend faux si elle n'y était pas — retirer deux
|
|
// fois n'est pas une erreur, mais l'appelant doit pouvoir le dire.
|
|
func (b BypassFile) Del(mac string) (string, bool, error) {
|
|
canon, err := NormalizeMAC(mac)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
entries, err := b.Load()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
var reste []BypassEntry
|
|
trouvee := false
|
|
for _, e := range entries {
|
|
if e.MAC == canon {
|
|
trouvee = true
|
|
continue
|
|
}
|
|
reste = append(reste, e)
|
|
}
|
|
if !trouvee {
|
|
return canon, false, nil
|
|
}
|
|
return canon, true, b.Save(reste)
|
|
}
|
|
|
|
// MACs rend les seules adresses, dans l'ordre du fichier.
|
|
func MACs(entries []BypassEntry) []string {
|
|
out := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, e.MAC)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BypassAddElement et BypassDelElement rendent le geste À CHAUD, à passer à
|
|
// « nft -f - ». Modifier l'ensemble ne repose pas les règles : les
|
|
// téléchargements des autres VM ne sont pas coupés, ce qu'un redémarrage du
|
|
// service ferait.
|
|
//
|
|
// Comme le reste de ce paquet, ces lignes sont RENDUES et jamais exécutées :
|
|
// l'appelant les applique, et un test les vérifie sans toucher au pare-feu de
|
|
// la machine qui l'exécute.
|
|
func BypassAddElement(mac string) string {
|
|
return fmt.Sprintf("add element ip %s %s { %s }",
|
|
TableName, BypassSetName, mac)
|
|
}
|
|
|
|
func BypassDelElement(mac string) string {
|
|
return fmt.Sprintf("delete element ip %s %s { %s }",
|
|
TableName, BypassSetName, mac)
|
|
}
|