Paillasse/correcteur/main.go

234 lines
6.7 KiB
Go
Raw Normal View History

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