233 lines
6.7 KiB
Go
233 lines
6.7 KiB
Go
// Correcteur Syllabus (étape 11) : exécute dans les VM des élèves les
|
|
// vérifications déclarées dans le lab.yaml, par l'agent invité QEMU.
|
|
//
|
|
// Il écoute sur un socket Unix accessible au seul groupe www-data (le
|
|
// portail) et n'accepte que deux formes de requête :
|
|
// - {instance, lab, verification} : la commande exécutée est toujours lue
|
|
// dans le lab.yaml, jamais reçue du portail ;
|
|
// - {instance, sante: true} (bloc A, self-healing) : état libvirt de la VM
|
|
// et guest-ping de son agent, sans rien exécuter dans la VM.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
tailleMaxRequete = 1024
|
|
delaiConnexion = 20 * time.Second
|
|
// README du pack de contenus : 10 s maximum par commande.
|
|
delaiVerification = 10 * time.Second
|
|
maxSimultanees = 8
|
|
// Sonde de santé : dumpxml + domstate + guest-ping (3 s).
|
|
delaiSonde = 8 * time.Second
|
|
)
|
|
|
|
var (
|
|
reInstance = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
|
reIdent = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
|
)
|
|
|
|
type requete struct {
|
|
Instance string `json:"instance"`
|
|
Lab string `json:"lab"`
|
|
Verification string `json:"verification"`
|
|
Sante bool `json:"sante"`
|
|
}
|
|
|
|
// valide : une sonde de santé ne porte ni lab ni vérification ; une
|
|
// vérification porte les deux.
|
|
func (r requete) valide() bool {
|
|
if !reInstance.MatchString(r.Instance) {
|
|
return false
|
|
}
|
|
if r.Sante {
|
|
return r.Lab == "" && r.Verification == ""
|
|
}
|
|
return reIdent.MatchString(r.Lab) && reIdent.MatchString(r.Verification)
|
|
}
|
|
|
|
type reponse struct {
|
|
Reussi bool `json:"reussi"`
|
|
Erreur string `json:"erreur,omitempty"`
|
|
// Sonde de santé seulement : état libvirt (« running », « shut off »,
|
|
// « paused », « crashed »…).
|
|
Etat string `json:"etat,omitempty"`
|
|
}
|
|
|
|
// Codes d'erreur renvoyés au portail (jamais la sortie de la commande).
|
|
const (
|
|
errRequete = "requete_invalide"
|
|
errLab = "lab_inconnu"
|
|
errVerification = "verification_inconnue"
|
|
errMachine = "machine_non_conforme"
|
|
errAgent = "agent_injoignable"
|
|
errDelai = "delai_depasse"
|
|
)
|
|
|
|
type config struct {
|
|
socket string
|
|
dossierLabs string
|
|
projet string
|
|
uidClient int
|
|
}
|
|
|
|
func main() {
|
|
cfg := config{
|
|
socket: envOu("CORRECTEUR_SOCKET", "/run/correcteur/correcteur.sock"),
|
|
dossierLabs: envOu("CORRECTEUR_LABS", "/opt/labs"),
|
|
projet: os.Getenv("CORRECTEUR_PROJET"),
|
|
}
|
|
uid, err := strconv.Atoi(os.Getenv("CORRECTEUR_UID_CLIENT"))
|
|
if err != nil || cfg.projet == "" {
|
|
log.Fatal("CORRECTEUR_PROJET et CORRECTEUR_UID_CLIENT sont obligatoires")
|
|
}
|
|
cfg.uidClient = uid
|
|
|
|
_ = os.Remove(cfg.socket)
|
|
ecoute, err := net.Listen("unix", cfg.socket)
|
|
if err != nil {
|
|
log.Fatalf("écoute impossible sur %s : %v", cfg.socket, err)
|
|
}
|
|
// Le groupe du processus est www-data (unité systemd) : 0660 réserve le
|
|
// socket à root et au portail.
|
|
if err := os.Chmod(cfg.socket, 0o660); err != nil {
|
|
log.Fatalf("chmod du socket : %v", err)
|
|
}
|
|
log.Printf("correcteur prêt sur %s (labs : %s)", cfg.socket, cfg.dossierLabs)
|
|
|
|
places := make(chan struct{}, maxSimultanees)
|
|
for {
|
|
conn, err := ecoute.Accept()
|
|
if err != nil {
|
|
log.Printf("accept : %v", err)
|
|
continue
|
|
}
|
|
places <- struct{}{}
|
|
go func() {
|
|
defer func() { <-places }()
|
|
traiter(cfg, conn.(*net.UnixConn))
|
|
}()
|
|
}
|
|
}
|
|
|
|
func traiter(cfg config, conn *net.UnixConn) {
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(delaiConnexion))
|
|
debut := time.Now()
|
|
|
|
// Défense en profondeur : en plus des droits du socket, on vérifie
|
|
// l'identité du processus à l'autre bout (SO_PEERCRED).
|
|
uid, err := uidPair(conn)
|
|
if err != nil || (uid != cfg.uidClient && uid != 0) {
|
|
log.Printf("connexion refusée (uid %d, %v)", uid, err)
|
|
return
|
|
}
|
|
|
|
var req requete
|
|
ligne, err := bufio.NewReader(io.LimitReader(conn, tailleMaxRequete)).ReadBytes('\n')
|
|
if err == nil {
|
|
dec := json.NewDecoder(bytes.NewReader(ligne))
|
|
dec.DisallowUnknownFields()
|
|
err = dec.Decode(&req)
|
|
}
|
|
var rep reponse
|
|
switch {
|
|
case err != nil || !req.valide():
|
|
rep = reponse{Erreur: errRequete}
|
|
case req.Sante:
|
|
rep = sonder(cfg, req.Instance)
|
|
default:
|
|
rep = verifier(cfg, req)
|
|
}
|
|
|
|
sortie, _ := json.Marshal(rep)
|
|
_, _ = conn.Write(append(sortie, '\n'))
|
|
if req.Sante {
|
|
// Une sonde par VM et par minute : on ne journalise que les échecs.
|
|
if !rep.Reussi {
|
|
log.Printf("sante instance=%s etat=%q erreur=%q duree=%s", req.Instance, rep.Etat, rep.Erreur, time.Since(debut).Round(time.Millisecond))
|
|
}
|
|
return
|
|
}
|
|
log.Printf("instance=%s lab=%s verification=%s reussi=%t erreur=%q duree=%s",
|
|
req.Instance, req.Lab, req.Verification, rep.Reussi, rep.Erreur, time.Since(debut).Round(time.Millisecond))
|
|
}
|
|
|
|
// sonder : la VM appartient-elle au projet, dans quel état est-elle, et son
|
|
// agent répond-il ? Reussi = agent qui répond.
|
|
func sonder(cfg config, instance string) reponse {
|
|
ctx, annuler := context.WithTimeout(context.Background(), delaiSonde)
|
|
defer annuler()
|
|
|
|
dom, err := lireDomaine(ctx, instance)
|
|
if err != nil || dom.projet() != cfg.projet {
|
|
return reponse{Erreur: errMachine}
|
|
}
|
|
etat, err := etatDomaine(ctx, instance)
|
|
if err != nil {
|
|
return reponse{Erreur: errMachine}
|
|
}
|
|
if etat != "running" {
|
|
return reponse{Etat: etat, Erreur: errAgent}
|
|
}
|
|
if err := pingAgent(ctx, instance); err != nil {
|
|
return reponse{Etat: etat, Erreur: errAgent}
|
|
}
|
|
return reponse{Reussi: true, Etat: etat}
|
|
}
|
|
|
|
func verifier(cfg config, req requete) reponse {
|
|
lab, err := chargerLab(cfg.dossierLabs, req.Lab)
|
|
if err != nil {
|
|
return reponse{Erreur: errLab}
|
|
}
|
|
v, ok := lab.verification(req.Verification)
|
|
if !ok {
|
|
return reponse{Erreur: errVerification}
|
|
}
|
|
|
|
ctx, annuler := context.WithTimeout(context.Background(), delaiVerification+5*time.Second)
|
|
defer annuler()
|
|
|
|
dom, err := lireDomaine(ctx, req.Instance)
|
|
if err != nil {
|
|
log.Printf("dumpxml %s : %v", req.Instance, err)
|
|
return reponse{Erreur: errMachine}
|
|
}
|
|
if dom.projet() != cfg.projet || lab.machineDuNom(dom.nom()) != v.Machine {
|
|
log.Printf("instance %s refusée : projet %q, nom %q, machine attendue %q", req.Instance, dom.projet(), dom.nom(), v.Machine)
|
|
return reponse{Erreur: errMachine}
|
|
}
|
|
|
|
ctxExec, annulerExec := context.WithTimeout(ctx, delaiVerification)
|
|
defer annulerExec()
|
|
code, sortieCmd, err := executer(ctxExec, req.Instance, v.Commande)
|
|
switch {
|
|
case errors.Is(err, errDelaiDepasse):
|
|
return reponse{Erreur: errDelai}
|
|
case err != nil:
|
|
log.Printf("guest-exec %s : %v", req.Instance, err)
|
|
return reponse{Erreur: errAgent}
|
|
}
|
|
|
|
return reponse{Reussi: v.Attendu.satisfait(code, sortieCmd)}
|
|
}
|
|
|
|
func envOu(cle, defaut string) string {
|
|
if v := os.Getenv(cle); v != "" {
|
|
return v
|
|
}
|
|
return defaut
|
|
}
|