Paillasse/proxy-console/hub.go
Adam Belkacemi 80b6c0722d ajoute le stock chaud, la correction automatique et les nouveaux essais de création de stack
Stock de stacks prêtes par lab dans Redis (prise atomique en Lua, ménage
par comparaison avec Heat, disjoncteur), files gérées par Horizon, séances
planifiables. Correcteur Go sur l'hyperviseur : socket Unix, commandes lues
dans le lab.yaml uniquement, exécution par l'agent invité QEMU. Une création
de stack ratée est supprimée et réessayée deux fois, puis la session passe
en échec, visible du prof.

Corrige aussi trois défauts des étapes précédentes : la console se coupait
à la première frappe d'un navigateur (trames texte), l'élève ne pouvait pas
rejoindre une séance, la déconnexion n'avait pas de jeton CSRF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:52:16 +02:00

159 lines
3.7 KiB
Go

package main
import (
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Un Hub représente une seule connexion amont vers nova-serialproxy, pour une
// seule VM (identifiée par son instance_id). La console série n'accepte
// qu'une connexion à la fois : le Hub est le seul à parler à nova-serialproxy,
// et duplique ce qu'il reçoit vers tous les navigateurs abonnés (l'élève qui
// écrit, le prof qui regarde en lecture seule).
type Hub struct {
instanceID string
upstream *websocket.Conn
mu sync.Mutex
subscribers map[*Client]bool
}
type Client struct {
conn *websocket.Conn
role string // "ecriture" ou "lecture"
hub *Hub
}
var (
hubsMu sync.Mutex
hubs = map[string]*Hub{}
)
var upstreamDialer = websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
Subprotocols: []string{"binary"},
}
// obtenirHub renvoie le Hub existant pour cette instance, ou en ouvre un
// nouveau (une seule connexion amont) si c'est le premier abonné.
func obtenirHub(instanceID, urlAmont string) (*Hub, error) {
hubsMu.Lock()
defer hubsMu.Unlock()
if hub, ok := hubs[instanceID]; ok {
return hub, nil
}
upstream, _, err := upstreamDialer.Dial(urlAmont, nil)
if err != nil {
return nil, err
}
hub := &Hub{
instanceID: instanceID,
upstream: upstream,
subscribers: map[*Client]bool{},
}
hubs[instanceID] = hub
go hub.pompeAmont()
log.Printf("console %s : connexion amont ouverte", instanceID)
return hub, nil
}
// pompeAmont lit en continu ce que renvoie nova-serialproxy et le recopie
// vers tous les navigateurs abonnés à cette VM.
func (h *Hub) pompeAmont() {
defer h.fermer()
for {
messageType, donnees, err := h.upstream.ReadMessage()
if err != nil {
return
}
h.mu.Lock()
for client := range h.subscribers {
_ = client.conn.WriteMessage(messageType, donnees)
}
h.mu.Unlock()
}
}
func (h *Hub) ajouter(c *Client) {
h.mu.Lock()
h.subscribers[c] = true
h.mu.Unlock()
}
// retirer enlève l'abonné ; si c'était le dernier, la connexion amont est
// fermée (plus personne ne regarde cette VM, pas de raison de la garder).
func (h *Hub) retirer(c *Client) {
h.mu.Lock()
delete(h.subscribers, c)
vide := len(h.subscribers) == 0
h.mu.Unlock()
if vide {
h.fermer()
}
}
func (h *Hub) fermer() {
hubsMu.Lock()
if hubs[h.instanceID] == h {
delete(hubs, h.instanceID)
}
hubsMu.Unlock()
_ = h.upstream.Close()
log.Printf("console %s : connexion amont fermée", h.instanceID)
}
// pompeClient lit ce que tape le navigateur et le relaie vers nova-serialproxy
// — uniquement si ce client a le rôle "ecriture". Un abonné en lecture seule
// ne peut rien envoyer : ses frappes sont simplement ignorées.
func (c *Client) pompeClient() {
defer c.hub.retirer(c)
defer c.conn.Close()
for {
_, donnees, err := c.conn.ReadMessage()
if err != nil {
return
}
if c.role != "ecriture" {
continue
}
// Toujours en binaire vers l'amont : xterm.js envoie des trames texte,
// et nova-serialproxy (sous-protocole « binary ») ferme la connexion
// à la première trame texte reçue (constaté le 2026-10-08, étape 11).
c.hub.mu.Lock()
err = c.hub.upstream.WriteMessage(websocket.BinaryMessage, donnees)
c.hub.mu.Unlock()
if err != nil {
return
}
}
}
// N'accepte que les connexions dont l'en-tête Origin correspond au site qui
// sert la page (reflété via Nginx) : empêche un autre site de faire ouvrir
// une console depuis le navigateur d'un élève à son insu.
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origine := r.Header.Get("Origin")
if origine == "" {
return true // outils en ligne de commande, pas de navigateur
}
return strings.HasSuffix(origine, "://"+r.Host)
},
}