Initial commit

This commit is contained in:
2023-11-30 19:32:38 +01:00
commit c8f076c2ab
13 changed files with 977 additions and 0 deletions

157
server/server.go Normal file
View File

@@ -0,0 +1,157 @@
package server
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
)
//go:embed static
var static embed.FS
type Server struct {
users *UserStore
streams *StreamStore
http.Server
}
func NewServer(store *UserStore) *Server {
srv := &Server{
users: store,
streams: NewStreamStore(),
}
r := chi.NewRouter()
r.Get("/", srv.StaticHandler)
r.Get("/{name}", srv.StaticHandler)
r.Post("/whip", http.HandlerFunc(srv.WhipHandler))
r.Get("/whip", srv.ListHandler)
r.Delete("/whip/{streamKey}", srv.DeleteHandler)
r.Post("/whip/{streamKey}", srv.PostOfferHandler)
srv.Handler = r
return srv
}
func (s *Server) StaticHandler(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
name = "index.html"
}
path := fmt.Sprintf("static/%s", name)
if strings.Contains(name, ".js") {
w.Header().Add("Content-Type", "text/javascript")
}
if strings.Contains(name, ".css") {
w.Header().Add("Content-Type", "text/css")
}
f, err := static.Open(path)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(w, f)
}
func (s *Server) PostOfferHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
streamKey := chi.URLParam(r, "streamKey")
stream, err := s.streams.Get(streamKey)
if err != nil {
slog.Warn("Unable to fetch stream.", "error", err, "stream_key", streamKey)
w.WriteHeader(http.StatusNotFound)
return
}
var buf bytes.Buffer
io.Copy(&buf, r.Body)
offer := &webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: buf.String()}
answer, err := stream.AddListener(offer)
if err != nil {
slog.Warn("Unable to add peer.", "error", err, "stream_key", streamKey, "buf", buf.String())
w.WriteHeader(http.StatusBadRequest)
return
}
io.WriteString(w, answer.SDP)
slog.Info("Got offer.", "stream", stream)
}
func (s *Server) DeleteHandler(w http.ResponseWriter, r *http.Request) {
streamKey := chi.URLParam(r, "streamKey")
s.streams.Delete(streamKey)
slog.Info("Deleted stream.", "streamKey", streamKey)
w.WriteHeader(http.StatusOK)
}
func (s *Server) ListHandler(w http.ResponseWriter, r *http.Request) {
streams := s.streams.List()
enc := json.NewEncoder(w)
enc.Encode(&streams)
}
func (s *Server) WhipHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
streamKey, err := streamKeyFromHeader(r.Header.Get("Authorization"))
if err != nil {
slog.Warn("Incoming request with invalid auth header.")
w.WriteHeader(http.StatusInternalServerError)
return
}
var buf bytes.Buffer
io.Copy(&buf, r.Body)
offer := &webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: buf.String()}
offerSdp := sdp.SessionDescription{}
if err := offerSdp.Unmarshal(buf.Bytes()); err != nil {
panic(err)
}
slog.Info("Got SDP.", "id", offerSdp.Origin.SessionID, "ip", offerSdp.Origin.UnicastAddress)
answer, err := s.streams.Add(streamKey, offer)
if err != nil {
slog.Error("Error adding stream.", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Add("Location", fmt.Sprintf("/whip/%s", streamKey))
w.WriteHeader(http.StatusCreated)
if _, err := io.WriteString(w, answer.SDP); err != nil {
slog.Error("Error writing response.", "error", err)
}
slog.Info("Wrote answer.")
}
func streamKeyFromHeader(header string) (string, error) {
split := strings.Split(header, " ")
if len(split) != 2 {
return "", fmt.Errorf("invalid header")
}
return split[1], nil
}

15
server/static/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>title</title>
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
</head>
<body>
<h1>HELLO!</h1>
<ol id="streamList">
</ol>
<video id="video1" width="1920" height="1080" autoplay muted></video>
</body>
</html>

66
server/static/script.js Normal file
View File

@@ -0,0 +1,66 @@
const startStream = function (streamKey) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
pc.oniceconnectionstatechange = e => console.log(e)
pc.addTransceiver('video')
pc.addTransceiver('audio')
pc.createOffer().then(offer => {
fetch("/whip/tjuice", {
method: "POST",
body: offer.sdp
}).then(resp => {
resp.text().then(text => {
var answer = {
type: "answer",
sdp: text
}
try {
pc.setLocalDescription(offer).then(
pc.setRemoteDescription(answer)
)
} catch (e) {
console.log("Error setting remote description: " + e)
}
})
});
})
pc.ontrack = function (event) {
console.log(event)
const el = document.getElementById('video1')
var ms = new MediaStream()
event.streams.forEach(s => {
const tracks = s.getTracks()
tracks.forEach( t => {
ms.addTrack(t)
})
})
el.srcObject = ms
el.autoplay = true
el.controls = true
}
}
displayStreams = function () {
fetch("/whip").then(resp => {
resp.json().then(streamList => {
for (const element of streamList) {
const ol = document.getElementById("streamList")
var entry = document.createElement("li")
var a = document.createElement("a")
a.onclick = _ => startStream(element)
a.append(document.createTextNode(element))
entry.appendChild(a)
ol.appendChild(entry)
}
})
})
}
window.onload = function ( ev ) {
console.log(ev)
displayStreams()
}

3
server/static/style.css Normal file
View File

@@ -0,0 +1,3 @@
body {
color: #ff5722;
}

249
server/stream.go Normal file
View File

@@ -0,0 +1,249 @@
package server
import (
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/intervalpli"
"github.com/pion/webrtc/v4"
)
var ErrNoSuchStream error = fmt.Errorf("no such stream")
type StreamStore struct {
Streams map[string]*Stream
mu sync.Mutex
}
func NewStreamStore() *StreamStore {
s := &StreamStore{
Streams: make(map[string]*Stream),
}
return s
}
type Stream struct {
peerConnection *webrtc.PeerConnection
lastUpdate time.Time
localTracks []*webrtc.TrackLocalStaticRTP
peers []*webrtc.PeerConnection
mu sync.Mutex
}
func (s *Stream) AddListener(sd *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
peerConnectionConfig := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
if err != nil {
return nil, err
}
for _, ltrack := range s.localTracks {
rtpSender, err := peerConnection.AddTrack(ltrack)
if err != nil {
// TODO, stop peerconn
return nil, err
}
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, err := rtpSender.Read(rtcpBuf); err != nil {
peerConnection.Close()
return
}
}
}()
}
err = peerConnection.SetRemoteDescription(*sd)
if err != nil {
return nil, err
}
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
return nil, err
}
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
s.mu.Lock()
s.peers = append(s.peers, peerConnection)
defer s.mu.Unlock()
// Get the LocalDescription and take it to base64 so we can paste in browser
return peerConnection.LocalDescription(), nil
}
func (s *StreamStore) Add(streamKey string, sd *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
answerChan := make(chan *webrtc.SessionDescription)
go func() {
stream := &Stream{
lastUpdate: time.Now(),
}
m := &webrtc.MediaEngine{}
if err := m.RegisterDefaultCodecs(); err != nil {
panic(err)
}
i := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(m, i); err != nil {
panic(err)
}
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
if err != nil {
panic(err)
}
i.Add(intervalPliFactory)
peerConnectionConfig := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i)).NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
stream.peerConnection = peerConnection
// Allow us to receive 1 video track
if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
panic(err)
}
if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
panic(err)
}
// Set a handler for when a new remote track starts, this just distributes all our packets
// to connected peers
peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
// Create a local track, all our SFU clients will be fed via this track
slog.Info("Got track!", "type", remoteTrack.Codec().MimeType, "id", remoteTrack.ID())
localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", "pion")
if newTrackErr != nil {
panic(newTrackErr)
}
stream.mu.Lock()
stream.localTracks = append(stream.localTracks, localTrack)
stream.mu.Unlock()
rtpBuf := make([]byte, 1400)
for {
i, _, readErr := remoteTrack.Read(rtpBuf)
if readErr != nil {
if errors.Is(readErr, io.EOF) {
slog.Warn("EOF from track.", "id", remoteTrack.ID())
return
}
panic(readErr)
}
// ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet
if _, err = localTrack.Write(rtpBuf[:i]); err != nil && !errors.Is(err, io.ErrClosedPipe) {
panic(err)
}
}
})
// Set the remote SessionDescription
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sd.SDP}
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
slog.Info("ICE Gathering complete.", "answer", answer)
answerChan <- &answer
s.Streams[streamKey] = stream
slog.Info("Added stream.", "stream_key", streamKey)
}()
answer := <-answerChan
return answer, nil
}
func (s *StreamStore) Get(streamKey string) (*Stream, error) {
stream, ok := s.Streams[streamKey]
if !ok {
return nil, ErrNoSuchStream
}
return stream, nil
}
func (s *StreamStore) Delete(streamKey string) error {
stream, ok := s.Streams[streamKey]
if !ok {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Streams, streamKey)
for _, peer := range stream.peers {
if err := peer.Close(); err != nil {
slog.Warn("Error closing peer.", "error", err)
}
}
return stream.peerConnection.Close()
}
func (s *StreamStore) List() []string {
streams := []string{}
for key := range s.Streams {
streams = append(streams, key)
}
return streams
}

100
server/users.go Normal file
View File

@@ -0,0 +1,100 @@
package server
import (
"encoding/json"
"fmt"
"io"
"os"
"golang.org/x/crypto/bcrypt"
)
type User struct {
Username string `json:"username"`
HashedPassword []byte `json:"hashed_password"`
IsAdmin bool `json:"is_admin"`
}
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.HashedPassword = hash
return nil
}
func (u *User) VerifyPassword(password string) error {
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password))
}
type UserStore struct {
users map[string]*User
}
func NewUserStore() *UserStore {
return &UserStore{
users: make(map[string]*User),
}
}
var ErrNoSuchUser = fmt.Errorf("no such user")
func (s *UserStore) Put(u *User) error {
s.users[u.Username] = u
return nil
}
func (s *UserStore) Get(username string) (*User, error) {
u, ok := s.users[username]
if !ok {
return nil, ErrNoSuchUser
}
return u, nil
}
func (s *UserStore) ToWriter(w io.Writer) error {
enc := json.NewEncoder(w)
if err := enc.Encode(&s.users); err != nil {
return err
}
return nil
}
func (s *UserStore) ToFile(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return s.ToWriter(f)
}
func StoreFromReader(r io.Reader) (*UserStore, error) {
dec := json.NewDecoder(r)
s := &UserStore{}
if err := dec.Decode(&s.users); err != nil {
return nil, err
}
return s, nil
}
func StoreFromFile(path string) (*UserStore, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return StoreFromReader(f)
}

89
server/users_test.go Normal file
View File

@@ -0,0 +1,89 @@
package server_test
import (
"bytes"
"testing"
"git.t-juice.club/torjus/ministream/server"
)
func TestPassword(t *testing.T) {
t.Run("SetAndVerify", func(t *testing.T) {
user := &server.User{}
password := "i L0ve K1tt3ns"
if err := user.SetPassword(password); err != nil {
t.Fatalf("Error setting password: %s", err)
}
if err := user.VerifyPassword(password); err != nil {
t.Fatalf("Error verifying with correct password: %s", err)
}
if err := user.VerifyPassword("wrong"); err == nil {
t.Errorf("Verifying with wrong password did not return error")
}
})
}
func TestUserStore(t *testing.T) {
t.Run("SaveLoad", func(t *testing.T) {
data := []struct {
Username string
Passord string
Admin bool
}{
{
Username: "admin",
Passord: "adminpw",
Admin: true,
},
{
Username: "dave",
Passord: "dave",
},
}
users := make(map[string]*server.User)
for _, item := range data {
u := &server.User{Username: item.Username, IsAdmin: item.Admin}
err := u.SetPassword(item.Passord)
if err != nil {
t.Fatalf("Error setting password: %s", err)
}
users[item.Username] = u
}
s := server.NewUserStore()
for _, u := range users {
if err := s.Put(u); err != nil {
t.Fatalf("Error storing user: %s", err)
}
}
// Write to buffer
var buf bytes.Buffer
if err := s.ToWriter(&buf); err != nil {
t.Fatalf("Error writing store to buffer: %s", err)
}
loaded, err := server.StoreFromReader(&buf)
if err != nil {
t.Fatalf("Error loading store: %s", err)
}
for _, item := range data {
u, err := loaded.Get(item.Username)
if err != nil {
t.Fatalf("Error getting user after load: %s", err)
}
if u.IsAdmin != item.Admin {
t.Fatalf("IsAdmin value changed.")
}
if err := u.VerifyPassword(item.Passord); err != nil {
t.Fatalf("Verifying password after load failed: %s", err)
}
}
})
}