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:
parent
30e14b1c54
commit
8742bde807
30 changed files with 1103 additions and 6 deletions
2
proxy-console/.gitignore
vendored
Normal file
2
proxy-console/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
proxy-console
|
||||
proxy-console-linux-amd64
|
||||
14
proxy-console/go.mod
Normal file
14
proxy-console/go.mod
Normal 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
22
proxy-console/go.sum
Normal 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
156
proxy-console/hub.go
Normal 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
99
proxy-console/main.go
Normal 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
|
||||
}
|
||||
58
proxy-console/test-duplication.py
Normal file
58
proxy-console/test-duplication.py
Normal 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)
|
||||
51
proxy-console/test-manuel.py
Normal file
51
proxy-console/test-manuel.py
Normal 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 ===")
|
||||
Loading…
Add table
Add a link
Reference in a new issue