ajoute le proxy console Go et le relais WebSocket vers nova-serialproxy

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-08 09:35:16 +02:00
parent 30e14b1c54
commit 8742bde807
30 changed files with 1103 additions and 6 deletions

2
proxy-console/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
proxy-console
proxy-console-linux-amd64

14
proxy-console/go.mod Normal file
View file

@ -0,0 +1,14 @@
module proxy-console
go 1.27.1
require (
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.23.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
go.uber.org/atomic v1.12.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)

22
proxy-console/go.sum Normal file
View file

@ -0,0 +1,22 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=

156
proxy-console/hub.go Normal file
View file

@ -0,0 +1,156 @@
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)
},
}

99
proxy-console/main.go Normal file
View file

@ -0,0 +1,99 @@
// Proxy console (étape 9 du déploiement Syllabus).
//
// Laravel demande à Nova l'adresse de la console série d'une VM, la range
// dans Redis sous un jeton aléatoire pour 5 minutes, et ne donne que ce
// jeton au navigateur. Ce service lit le jeton, ouvre (ou réutilise) la
// connexion WebSocket vers nova-serialproxy, et recopie les octets dans les
// deux sens — en lecture seule pour les abonnés qui n'ont pas le droit
// d'écrire (voir hub.go).
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
// Doit correspondre exactement à ce que Laravel écrit dans Redis
// (App\Services\OpenStack\JetonConsole).
type jetonConsole struct {
URL string `json:"url"`
InstanceID string `json:"instance_id"`
Role string `json:"role"`
}
var rdb *redis.Client
func main() {
adresseEcoute := env("PROXY_CONSOLE_ECOUTE", "127.0.0.1:8081")
adresseRedis := env("PROXY_CONSOLE_REDIS", "127.0.0.1:6380")
motDePasseRedis := os.Getenv("PROXY_CONSOLE_REDIS_MOT_DE_PASSE")
rdb = redis.NewClient(&redis.Options{
Addr: adresseRedis,
Password: motDePasseRedis,
})
http.HandleFunc("/console/", gererConsole)
http.HandleFunc("/sante", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("proxy-console à l'écoute sur %s (Redis %s)", adresseEcoute, adresseRedis)
log.Fatal(http.ListenAndServe(adresseEcoute, nil))
}
func gererConsole(w http.ResponseWriter, r *http.Request) {
jeton := r.URL.Path[len("/console/"):]
if jeton == "" {
http.Error(w, "jeton manquant", http.StatusBadRequest)
return
}
valeur, err := rdb.Get(context.Background(), "console:"+jeton).Result()
if err == redis.Nil {
http.Error(w, "jeton inconnu ou expiré", http.StatusNotFound)
return
}
if err != nil {
log.Printf("erreur Redis pour le jeton %s : %v", jeton, err)
http.Error(w, "erreur serveur", http.StatusInternalServerError)
return
}
var jc jetonConsole
if err := json.Unmarshal([]byte(valeur), &jc); err != nil {
http.Error(w, "jeton corrompu", http.StatusInternalServerError)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("échec de l'upgrade WebSocket : %v", err)
return
}
hub, err := obtenirHub(jc.InstanceID, jc.URL)
if err != nil {
log.Printf("échec de connexion à nova-serialproxy pour %s : %v", jc.InstanceID, err)
_ = conn.WriteMessage(websocket.TextMessage, []byte("\r\nConsole indisponible.\r\n"))
_ = conn.Close()
return
}
client := &Client{conn: conn, role: jc.Role, hub: hub}
hub.ajouter(client)
client.pompeClient()
}
func env(cle, defaut string) string {
if v := os.Getenv(cle); v != "" {
return v
}
return defaut
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Vérifie la duplication du flux (étape 9) : l'élève se connecte et garde
la connexion ouverte, le prof se connecte en parallèle sur le même instant
et doit voir ce que tape l'élève, sans pouvoir rien y faire lui-même."""
import sys
import threading
import time
import websocket
jeton_eleve = sys.argv[1] # URL ws:// complète
jeton_prof = sys.argv[2] # URL ws:// complète
sortie_prof = []
def ecouter_prof():
ws = websocket.create_connection(jeton_prof, timeout=10)
ws.settimeout(8)
fin = time.time() + 8
while time.time() < fin:
try:
m = ws.recv()
except websocket.WebSocketTimeoutException:
break
sortie_prof.append(m if isinstance(m, str) else m.decode(errors="replace"))
# Le prof essaie d'écrire : ça ne doit avoir aucun effet côté VM.
ws.send_binary(b"COMMANDE-INTERDITE-DU-PROF\r")
ws.close()
ws_eleve = websocket.create_connection(jeton_eleve, timeout=10)
ws_eleve.send_binary(b"eleve\r")
time.sleep(1)
ws_eleve.send_binary(b"eleve\r")
time.sleep(1)
fil_prof = threading.Thread(target=ecouter_prof)
fil_prof.start()
time.sleep(1)
ws_eleve.send_binary(b"echo MARQUEUR-ELEVE-UNIQUE\r")
time.sleep(2)
ws_eleve.settimeout(2)
try:
while True:
ws_eleve.recv()
except Exception:
pass
fil_prof.join()
ws_eleve.close()
texte_prof = "".join(sortie_prof)
print("Le prof a-t-il vu le marqueur de l'élève ?", "MARQUEUR-ELEVE-UNIQUE" in texte_prof)
print("Le prof a-t-il vu sa propre commande interdite s'exécuter ? (ne doit jamais arriver)",
"COMMANDE-INTERDITE" in texte_prof and "command not found" not in texte_prof)

View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Test manuel du proxy-console, comme le ferait le navigateur (étape 9) :
pas de sous-protocole demandé, contrairement au test direct de nova-serialproxy."""
import sys
import time
import websocket
url = sys.argv[1]
commande = sys.argv[2] if len(sys.argv) > 2 else "echo bonjour-syllabus"
def lire(ws, duree=3):
fin = time.time() + duree
sortie = b""
while time.time() < fin:
ws.settimeout(max(0.2, fin - time.time()))
try:
morceau = ws.recv()
except websocket.WebSocketTimeoutException:
break
except websocket.WebSocketConnectionClosedException:
print("[connexion fermée par le serveur]")
break
if isinstance(morceau, str):
morceau = morceau.encode()
if not morceau:
break
sortie += morceau
return sortie
ws = websocket.create_connection(url, timeout=10)
print("=== lecture initiale ===")
sys.stdout.buffer.write(lire(ws, 3))
print("\n=== connexion : eleve ===")
ws.send_binary(b"eleve\r")
sys.stdout.buffer.write(lire(ws, 2))
print("\n=== mot de passe ===")
ws.send_binary(b"eleve\r")
sys.stdout.buffer.write(lire(ws, 2))
print(f"\n=== commande : {commande} ===")
ws.send_binary((commande + "\r").encode())
sys.stdout.buffer.write(lire(ws, 3))
ws.close()
print("\n=== fin ===")