Stagione 94 / Episodio 9
Il cantiere
Mettiamo insieme i pezzi nel codice: un server di segnalazione in Go con stanze e ruoli per la negoziazione perfetta, le credenziali TURN temporanee, un client JavaScript, una riva scritta interamente in Go con Pion e le regole per portare tutto in produzione dietro nginx.
Note dell'episodio
Bentornati sulle due rive. Per otto episodi abbiamo studiato il progetto del ponte. Oggi apriamo il cantiere e lo costruiamo davvero, pezzo per pezzo, nel codice.
Il risultato sarà piccolo ma completo: due persone aprono la stessa pagina, scelgono una stanza, e si vedono e si sentono. Tutto il codice è nelle note della puntata, ed è stato provato tra due browser. Qui raccontiamo le scelte, perché ogni riga corrisponde a qualcosa che abbiamo visto nella stagione.
Partiamo dalla pianta. Il cantiere ha quattro pezzi.
Il primo è il messaggero: un server scritto in Go che accetta connessioni WebSocket, mette le persone in stanze da due e inoltra le lettere. Il secondo è un piccolo indirizzo dello stesso server che restituisce la configurazione dei server STUN e TURN, con le credenziali temporanee che abbiamo visto nel quarto episodio. Il terzo è la pagina, con un file JavaScript che fa da riva. Il quarto è il traghetto, coturn, che gira separato, possibilmente su un'altra macchina.
Per il messaggero usiamo la libreria gorilla slash websocket, la più diffusa in Go. Tutto il resto è libreria standard.
Il cuore del server è una mappa di stanze, protetta da un mutex. Ogni stanza contiene al massimo due pari. Se arriva un terzo, riceve un messaggio che dice stanza piena, e la connessione si chiude.
La parte interessante è il ruolo. Ricordate la negoziazione perfetta del secondo episodio? Una riva deve essere educata, l'altra scortese. Chi lo decide? Il messaggero, che è l'unico a vedere entrambe. Il primo che entra è scortese, il secondo educato. E c'è un dettaglio che si scopre solo provando: se il secondo esce e ne entra un terzo, il nuovo arrivato deve prendere il ruolo opposto a chi è rimasto, non semplicemente il ruolo del secondo. Altrimenti potreste ritrovarvi con due rive educate, che in caso di collisione cedono entrambe, e il ponte non si costruisce più.
Quando il secondo pari entra, il server avvisa tutti e due con un messaggio, peer joined. Da quel momento ogni lettera che arriva da una riva viene inoltrata all'altra.
E qui c'è una scelta di progetto importante: il server non legge le lettere. Controlla solo che il messaggio abbia la forma giusta, e inoltra il contenuto così com'è, senza interpretarlo. Il messaggero non apre le buste. È più semplice, e il server non ha bisogno di sapere nulla di SDP.
Tre dettagli del messaggero vengono dall'esperienza, e valgono per qualunque server WebSocket in Go.
Il primo: la libreria gorilla permette una sola goroutine che scrive su una connessione alla volta. Se due pari inviano nello stesso momento e due goroutine scrivono sulla stessa connessione, il programma può corrompere i dati. La soluzione classica è una coda per ogni client, un canale Go, e una sola goroutine che la svuota e scrive. Tutti gli altri mettono i messaggi nella coda. E la coda va chiusa una volta sola, con un piccolo lucchetto, perché inviare su un canale chiuso in Go fa crollare il programma.
Il secondo: un limite alla dimensione dei messaggi in lettura. Un SDP sta comodamente in sessantaquattro kilobyte. Senza limite, chiunque può mandare un messaggio enorme e riempire la memoria del server.
Il terzo: il controllo dell'origine. Un WebSocket non è protetto dalle regole che i browser applicano alle richieste tra siti diversi. Se non controllate l'intestazione Origin, qualunque sito può aprire una connessione al vostro messaggero dal browser dei vostri utenti. Il nostro server accetta solo la propria origine, o quella indicata in una variabile d'ambiente.
Infine, un ping ogni cinquanta secondi circa: se il client non risponde, la connessione viene considerata morta e chiusa. Altrimenti, una rete mobile che sparisce lascia stanze fantasma occupate per sempre.
Il file JavaScript è breve, perché il lavoro difficile lo fa il browser.
Si collega al messaggero, riceve il proprio ruolo, e aspetta. Solo quando arriva il messaggio peer joined chiede al server la configurazione dei server ICE, crea la RTCPeerConnection, chiede microfono e videocamera e aggiunge le tracce. Perché aspettare? Perché aggiungere una traccia scatena subito l'evento negotiationneeded, e se l'altra riva non c'è ancora, l'offerta parte verso il nulla e si perde.
Il resto è esattamente la negoziazione perfetta: le stesse trenta righe del secondo episodio, con in più il riavvio di ICE quando la connessione fallisce. Quando l'altra riva se ne va, chiudiamo il ponte e restiamo in attesa del prossimo.
Se usate Gin, come in molti progetti in Go, non cambia nulla: gli stessi gestori si collegano alle rotte passando il writer e la richiesta del contesto. Nelle note c'è l'esempio.
Il pezzo forse più sorprendente del cantiere è questo: una riva che non è un browser.
Pion è una realizzazione completa di WebRTC scritta interamente in Go, senza dipendenze in C. ICE, DTLS, SRTP, SCTP, tutto in Go. Significa che un vostro programma in Go può essere un pari a tutti gli effetti: ricevere video da un browser, registrarlo, mandare dati sui canali.
Nel nostro esempio la riva in Go fa una cosa semplice: riceve l'offerta del browser con una richiesta POST, crea la risposta, e rimanda indietro ogni messaggio che arriva su un canale dati, come un'eco. La segnalazione è nello stile di WHIP, che abbiamo visto nell'episodio scorso: una richiesta, una risposta con il codice duecentouno. Per farlo in una sola richiesta rinunciamo a trickle ICE: il server aspetta di aver raccolto tutti i candidati, con un tempo massimo di cinque secondi, e li mette tutti nella risposta. Il browser fa lo stesso prima di mandare l'offerta.
Con questa base potete costruire molto: un registratore di chiamate, un bot che risponde sui canali dati, un ponte verso altri sistemi. Ed è lo stesso motore su cui è costruito LiveKit.
Restano i dettagli della messa in produzione.
Il messaggero sta dietro a nginx, che gestisce l'HTTPS. Per il WebSocket servono due intestazioni da inoltrare, Upgrade e Connection, e il protocollo HTTP uno punto uno verso il server. Poi un dettaglio che fa perdere ore: nginx chiude per impostazione predefinita le connessioni inattive dopo sessanta secondi. Il nostro ping arriva prima, ma conviene comunque alzare il tempo massimo, perché una chiamata può durare ore.
Il traghetto, coturn, va su una macchina con indirizzo pubblico e le porte UDP aperte. Non mettetelo dietro a nginx: nginx non c'entra con il traffico TURN.
E infine, le regole del cantiere, che riassumono la stagione. Primo, HTTPS ovunque, perché senza contesto sicuro le API non esistono. Secondo, il messaggero su un canale cifrato, perché è lui a portare la parola d'ordine. Terzo, la negoziazione perfetta fin dall'inizio, anche se oggi le collisioni sembrano impossibili. Quarto, sempre un traghetto, con credenziali temporanee. Quinto, il riavvio di ICE quando la connessione fallisce. Sesto, il collaudo con la politica relay prima di ogni rilascio. Settimo, guardare le statistiche, ed è il tema del prossimo episodio.
Quindi, ricapitolando: il cantiere ha un messaggero in Go che assegna i ruoli e inoltra le lettere senza aprirle, un client che costruisce il ponte con la negoziazione perfetta, e una riva scritta in Go con Pion. In produzione: nginx davanti al WebSocket, coturn su una macchina sua, e sette regole da non dimenticare.
Il ponte è costruito. Ma come si fa a capire perché una chiamata, per una certa persona, va a scatti? Nel prossimo episodio, l'ultimo della stagione: il collaudo e l'orizzonte. Ci sentiamo lì.