Paillasse/proxy-console/hub.go
Adam Belkacemi 8742bde807 ajoute le proxy console Go et le relais WebSocket vers nova-serialproxy
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 09:35:16 +02:00

156 lines
3.5 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 {
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)
},
}