mirror of
				https://gitlab.crans.org/nounous/ghostream.git
				synced 2025-11-04 04:12:10 +01:00 
			
		
		
		
	Use POST to exchange WebRTC sessions
This commit is contained in:
		
							
								
								
									
										22
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# Ghostream
 | 
			
		||||
 | 
			
		||||
[](https://www.gnu.org/licenses/gpl-2.0.txt)
 | 
			
		||||
[](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)*
 | 
			
		||||
@@ -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]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										68
									
								
								web/web.go
									
									
									
									
									
								
							
							
						
						
									
										68
									
								
								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))
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user