diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc8ad9b --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Ghostream + +[![License: GPL v2](https://img.shields.io/badge/License-GPL%20v2-blue.svg)](https://www.gnu.org/licenses/gpl-2.0.txt) +[![pipeline status](https://gitlab.crans.org/nounous/ghostream/badges/master/pipeline.svg)](https://gitlab.crans.org/nounous/ghostream/commits/master) + +*Boooo!* A simple streaming server with authentication and open-source technologies. + +Features: + +- WebRTC playback with a lightweight web interface. +- Low-latency streaming, sub-second with web player. +- Authentification of incoming stream using LDAP server. + +## Installation with Docker + +An example is given in [docs/docker-compose.yml](doc/docker-compose.yml). +It uses Traefik reverse proxy. + +## References + +- Phil Cluff (2019), *[Streaming video on the internet without MPEG.](https://mux.com/blog/streaming-video-on-the-internet-without-mpeg/)* +- MDN web docs, *[Signaling and video calling.](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling)* diff --git a/web/static/js/viewer.js b/web/static/js/viewer.js index 007ac33..c408c87 100644 --- a/web/static/js/viewer.js +++ b/web/static/js/viewer.js @@ -1,5 +1,5 @@ // Init peer connection -let pc = new RTCPeerConnection({ +peerConnection = new RTCPeerConnection({ iceServers: [ { // FIXME: let admin customize the stun server @@ -8,33 +8,58 @@ let pc = new RTCPeerConnection({ ] }) -// Create an offer to receive one video and one audio track -pc.addTransceiver('video', { 'direction': 'sendrecv' }) -pc.addTransceiver('audio', { 'direction': 'sendrecv' }) -pc.createOffer().then(d => pc.setLocalDescription(d)).catch(console.log) +// On connection change, inform user +peerConnection.oniceconnectionstatechange = e => { + console.log(peerConnection.iceConnectionState) -// When local session description is ready, send it to streaming server -// FIXME: also send stream path -// FIXME: send to wss://{{.Cfg.Hostname}}/play/{{.Path}} -pc.oniceconnectionstatechange = e => console.log(pc.iceConnectionState) -pc.onicecandidate = event => { + switch (myPeerConnection.iceConnectionState) { + case "closed": + case "failed": + console.log("FIXME Failed"); + break; + case "disconnected": + console.log("temp network issue") + break; + case "connected": + console.log("temp network issue resolved!") + break; + } +} + +// We want to receive audio and video +peerConnection.addTransceiver('video', { 'direction': 'sendrecv' }) +peerConnection.addTransceiver('audio', { 'direction': 'sendrecv' }) + +// Create offer and set local description +peerConnection.createOffer().then(offer => { + // After setLocalDescription, the browser will fire onicecandidate events + peerConnection.setLocalDescription(offer) +}).catch(console.log) + +// When candidate is null, ICE layer has run out of potential configurations to suggest +// so let's send the offer to the server +peerConnection.onicecandidate = event => { if (event.candidate === null) { - document.getElementById('localSessionDescription').value = JSON.stringify(pc.localDescription) + // Send offer to server + // The server know the stream name from the url + // The server replies with its description + // After setRemoteDescription, the browser will fire ontrack events + fetch(window.location.href, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(peerConnection.localDescription) + }) + .then(response => response.json()) + .then((data) => peerConnection.setRemoteDescription(new RTCSessionDescription(data))) + .catch(console.log) } } -// When remote session description is received, load it -window.startSession = () => { - let sd = document.getElementById('remoteSessionDescription').value - try { - pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(sd))) - } catch (e) { - console.log(e) - } -} - -// When video track is received, mount player -pc.ontrack = function (event) { +// When video track is received, configure player +peerConnection.ontrack = function (event) { if (event.track.kind === "video") { const viewer = document.getElementById('viewer') viewer.srcObject = event.streams[0] diff --git a/web/web.go b/web/web.go index 8d220cb..470cb93 100644 --- a/web/web.go +++ b/web/web.go @@ -1,11 +1,13 @@ package web import ( + "encoding/json" "html/template" "log" "net/http" "os" + "github.com/pion/webrtc/v3" "gitlab.crans.org/nounous/ghostream/internal/monitoring" ) @@ -21,35 +23,60 @@ type Options struct { // Preload templates var templates = template.Must(template.ParseGlob("web/template/*.html")) -// Handle site index and viewer pages -func viewerHandler(w http.ResponseWriter, r *http.Request, cfg *Options) { - // Data for template - data := struct { - Path string - Cfg *Options - }{Path: r.URL.Path[1:], Cfg: cfg} +// Handle WebRTC session description exchange via POST +func sessionExchangeHandler(w http.ResponseWriter, r *http.Request) { + // Limit response body to 128KB + r.Body = http.MaxBytesReader(w, r.Body, 131072) + // Decode client description + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + remoteDescription := webrtc.SessionDescription{} + if err := dec.Decode(&remoteDescription); err != nil { + http.Error(w, "The JSON WebRTC offer is malformed", http.StatusBadRequest) + return + } + + // FIXME remoteDescription -> "Magic" -> localDescription + localDescription := remoteDescription + + // Send server description as JSON + jsonDesc, err := json.Marshal(localDescription) + if err != nil { + http.Error(w, "An error occured while formating response", http.StatusInternalServerError) + log.Println("An error occured while sending session description", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jsonDesc) +} + +// Handle site index and viewer pages +// POST requests are used to exchange WebRTC session descriptions +func viewerHandler(w http.ResponseWriter, r *http.Request, cfg *Options) { // FIXME validation on path: https://golang.org/doc/articles/wiki/#tmp_11 - // Render template - err := templates.ExecuteTemplate(w, "base", data) - if err != nil { - log.Println(err.Error()) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + switch r.Method { + case "GET": + // Render template + data := struct { + Path string + Cfg *Options + }{Path: r.URL.Path[1:], Cfg: cfg} + if err := templates.ExecuteTemplate(w, "base", data); err != nil { + log.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + case "POST": + sessionExchangeHandler(w, r) + default: + http.Error(w, "Sorry, only GET and POST methods are supported.", http.StatusBadRequest) } // Increment monitoring monitoring.ViewerServed.Inc() } -// Auth incoming stream -func streamAuthHandler(w http.ResponseWriter, r *http.Request, cfg *Options) { - // FIXME POST request only with "name" and "pass" - // if name or pass missing => 400 Malformed request - // else login in against LDAP or static users - http.Error(w, "Not implemented", 400) -} - // Handle static files // We do not use http.FileServer as we do not want directory listing func staticHandler(w http.ResponseWriter, r *http.Request, cfg *Options) { @@ -73,7 +100,6 @@ func ServeHTTP(cfg *Options) { // Set up HTTP router and server mux := http.NewServeMux() mux.HandleFunc("/", makeHandler(viewerHandler, cfg)) - mux.HandleFunc("/rtmp/auth", makeHandler(streamAuthHandler, cfg)) mux.HandleFunc("/static/", makeHandler(staticHandler, cfg)) log.Printf("HTTP server listening on %s", cfg.ListenAddress) log.Fatal(http.ListenAndServe(cfg.ListenAddress, mux))