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 { messageType, donnees, err := c.conn.ReadMessage() if err != nil { return } if c.role != "ecriture" { continue } c.hub.mu.Lock() err = c.hub.upstream.WriteMessage(messageType, 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) }, }