1
Fork 0

Initial commit.

This commit is contained in:
Juliusz Chroboczek 2020-04-24 19:38:21 +02:00
commit f5a518a448
12 changed files with 2454 additions and 0 deletions

841
client.go Normal file
View File

@ -0,0 +1,841 @@
// Copyright (c) 2020 by Juliusz Chroboczek.
// This is not open source software. Copy it, and I'll break into your
// house and tell your three year-old that Santa doesn't exist.
package main
import (
"encoding/json"
"errors"
"io"
"log"
"math"
"os"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/pion/rtcp"
"github.com/pion/sdp"
"github.com/pion/webrtc/v2"
)
var iceConf webrtc.Configuration
var iceOnce sync.Once
func iceConfiguration() webrtc.Configuration {
iceOnce.Do(func() {
var iceServers []webrtc.ICEServer
file, err := os.Open(iceFilename)
if err != nil {
log.Printf("Open %v: %v", iceFilename, err)
return
}
defer file.Close()
d := json.NewDecoder(file)
err = d.Decode(&iceServers)
if err != nil {
log.Printf("Get ICE configuration: %v", err)
return
}
iceConf = webrtc.Configuration{
ICEServers: iceServers,
}
})
return iceConf
}
type protocolError string
func (err protocolError) Error() string {
return string(err)
}
func errorToWSCloseMessage(err error) []byte {
var code int
var text string
switch e := err.(type) {
case *websocket.CloseError:
code = websocket.CloseNormalClosure
case protocolError:
code = websocket.CloseProtocolError
text = string(e)
default:
code = websocket.CloseInternalServerErr
}
return websocket.FormatCloseMessage(code, text)
}
func isWSNormalError(err error) bool {
return websocket.IsCloseError(err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway)
}
type clientMessage struct {
Type string `json:"type"`
Id string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Group string `json:"group,omitempty"`
Value string `json:"value,omitempty"`
Me *bool `json:"me,omitempty"`
Offer *webrtc.SessionDescription `json:"offer,omitempty"`
Answer *webrtc.SessionDescription `json:"answer,omitempty"`
Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"`
Del bool `json:"del,omitempty"`
AudioRate int `json:"audiorate,omitempty"`
VideoRate int `json:"audiorate,omitempty"`
}
type closeMessage struct {
data []byte
}
func startClient(conn *websocket.Conn) (err error) {
var m clientMessage
err = conn.ReadJSON(&m)
if err != nil {
return
}
if m.Type != "handshake" {
err = protocolError("expected handshake")
return
}
c := &client{
id: m.Id,
username: m.Username,
actionCh: make(chan interface{}, 10),
done: make(chan struct{}),
}
defer close(c.done)
c.writeCh = make(chan interface{}, 1)
defer func() {
if isWSNormalError(err) {
err = nil
} else {
select {
case c.writeCh <- closeMessage{
errorToWSCloseMessage(err),
}:
case <-c.writerDone:
}
}
close(c.writeCh)
c.writeCh = nil
}()
c.writerDone = make(chan struct{})
go clientWriter(conn, c.writeCh, c.writerDone)
g, users, err := addClient(m.Group, c)
if err != nil {
return
}
c.group = g
defer delClient(c)
for _, u := range users {
c.write(clientMessage{
Type: "user",
Id: u.id,
Username: u.username,
})
}
clients := g.getClients(nil)
u := clientMessage{
Type: "user",
Id: c.id,
Username: c.username,
}
for _, c := range clients {
c.write(u)
}
defer func() {
clients := g.getClients(c)
u := clientMessage{
Type: "user",
Id: c.id,
Username: c.username,
Del: true,
}
for _, c := range clients {
c.write(u)
}
}()
return clientLoop(c, conn)
}
func getUpConn(c *client, id string) *upConnection {
c.group.mu.Lock()
defer c.group.mu.Unlock()
if c.up == nil {
return nil
}
conn := c.up[id]
if conn == nil {
return nil
}
return conn
}
func addUpConn(c *client, id string) (*upConnection, error) {
pc, err := groups.api.NewPeerConnection(iceConfiguration())
if err != nil {
return nil, err
}
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
webrtc.RtpTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionRecvonly,
},
)
if err != nil {
pc.Close()
return nil, err
}
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
webrtc.RtpTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionRecvonly,
},
)
if err != nil {
pc.Close()
return nil, err
}
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
sendICE(c, id, candidate)
})
pc.OnTrack(func(remote *webrtc.Track, receiver *webrtc.RTPReceiver) {
local, err := pc.NewTrack(
remote.PayloadType(),
remote.SSRC(),
remote.ID(),
remote.Label())
if err != nil {
log.Printf("%v", err)
return
}
c.group.mu.Lock()
u, ok := c.up[id]
if !ok {
log.Printf("Unknown connection")
c.group.mu.Unlock()
return
}
u.pairs = append(u.pairs, trackPair{
remote: remote,
local: local,
})
done := len(u.pairs) >= u.streamCount
c.group.mu.Unlock()
clients := c.group.getClients(c)
for _, cc := range clients {
cc.action(addTrackAction{id, local, u, done})
if(done && u.label != "") {
cc.action(addLabelAction{id, u.label})
}
}
go func() {
buf := make([]byte, 1500)
for {
i, err := remote.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("%v", err)
}
break
}
_, err = local.Write(buf[:i])
if err != nil && err != io.ErrClosedPipe {
log.Printf("%v", err)
}
}
}()
})
conn := &upConnection{id: id, pc: pc}
c.group.mu.Lock()
defer c.group.mu.Unlock()
if c.up == nil {
c.up = make(map[string]*upConnection)
}
if c.up[id] != nil {
conn.pc.Close()
return nil, errors.New("Adding duplicate connection")
}
c.up[id] = conn
return conn, nil
}
func delUpConn(c *client, id string) {
c.group.mu.Lock()
defer c.group.mu.Unlock()
if c.up == nil {
log.Printf("Deleting unknown connection")
return
}
conn := c.up[id]
if conn == nil {
log.Printf("Deleting unknown connection")
return
}
type clientId struct {
client *client
id string
}
cids := make([]clientId, 0)
for _, cc := range c.group.clients {
for _, otherconn := range cc.down {
if otherconn.remote == conn {
cids = append(cids, clientId{cc, otherconn.id})
}
}
}
for _, cid := range cids {
cid.client.action(delPCAction{cid.id})
}
conn.pc.Close()
delete(c.up, id)
}
func getDownConn(c *client, id string) *downConnection {
if c.down == nil {
return nil
}
c.group.mu.Lock()
defer c.group.mu.Unlock()
conn := c.down[id]
if conn == nil {
return nil
}
return conn
}
func addDownConn(c *client, id string, remote *upConnection) (*downConnection, error) {
pc, err := groups.api.NewPeerConnection(iceConfiguration())
if err != nil {
return nil, err
}
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
sendICE(c, id, candidate)
})
pc.OnTrack(func(remote *webrtc.Track, receiver *webrtc.RTPReceiver) {
log.Printf("Got track on downstream connection")
})
if c.down == nil {
c.down = make(map[string]*downConnection)
}
conn := &downConnection{id: id, pc: pc, remote: remote}
c.group.mu.Lock()
defer c.group.mu.Unlock()
if c.down[id] != nil {
conn.pc.Close()
return nil, errors.New("Adding duplicate connection")
}
c.down[id] = conn
return conn, nil
}
func delDownConn(c *client, id string) {
c.group.mu.Lock()
defer c.group.mu.Unlock()
if c.down == nil {
log.Printf("Deleting unknown connection")
return
}
conn := c.down[id];
if conn == nil {
log.Printf("Deleting unknown connection")
return
}
conn.pc.Close()
delete(c.down, id)
}
func addDownTrack(c *client, id string, track *webrtc.Track, remote *upConnection) (*downConnection, *webrtc.RTPSender, error) {
conn := getDownConn(c, id)
if conn == nil {
var err error
conn, err = addDownConn(c, id, remote)
if err != nil {
return nil, nil, err
}
}
s, err := conn.pc.AddTrack(track)
if err != nil {
return nil, nil, err
}
go rtcpListener(c.group, conn, s)
return conn, s, nil
}
func rtcpListener(g *group, c *downConnection, s *webrtc.RTPSender) {
for {
ps, err := s.ReadRTCP()
if err != nil {
if err != io.EOF {
log.Printf("ReadRTCP: %v", err)
}
return
}
for _, p := range ps {
switch p := p.(type) {
case *rtcp.PictureLossIndication:
err := sendPli(g, s.Track(), c.remote)
if err != nil {
log.Printf("sendPli: %v", err)
}
case *rtcp.ReceiverEstimatedMaximumBitrate:
bitrate := uint32(math.MaxInt32)
if p.Bitrate < math.MaxInt32 {
bitrate = uint32(p.Bitrate)
}
atomic.StoreUint32(&c.maxBitrate, bitrate)
case *rtcp.ReceiverReport:
default:
log.Printf("RTCP: %T", p)
}
}
}
}
func trackKinds(down *downConnection) (audio bool, video bool) {
if down.pc == nil {
return
}
for _, s := range down.pc.GetSenders() {
track := s.Track()
if track == nil {
continue
}
switch track.Kind() {
case webrtc.RTPCodecTypeAudio:
audio = true
case webrtc.RTPCodecTypeVideo:
video = true
}
}
return
}
func splitBitrate(bitrate uint32, audio, video bool) (uint32, uint32) {
if audio && !video {
return bitrate, 0
}
if !audio && video {
return 0, bitrate
}
if bitrate < 6000 {
return 6000, 0
}
if bitrate < 12000 {
return bitrate, 0
}
audioRate := 8000 + (bitrate-8000)/4
if audioRate > 96000 {
audioRate = 96000
}
return audioRate, bitrate - audioRate
}
func updateBitrate(g *group, up *upConnection) (uint32, uint32) {
audio := uint32(math.MaxInt32)
video := uint32(math.MaxInt32)
g.Range(func(c *client) bool {
for _, down := range c.down {
if down.remote == up {
bitrate := atomic.LoadUint32(&down.maxBitrate)
if bitrate == 0 {
bitrate = 256000
} else if bitrate < 6000 {
bitrate = 6000
}
hasAudio, hasVideo := trackKinds(down)
a, v := splitBitrate(bitrate, hasAudio, hasVideo)
if a < audio {
audio = a
}
if v < video {
video = v
}
}
}
return true
})
up.maxAudioBitrate = audio
up.maxVideoBitrate = video
return audio, video
}
func sendPli(g *group, local *webrtc.Track, up *upConnection) error {
var track *webrtc.Track
for _, p := range up.pairs {
if p.local == local {
track = p.remote
break
}
}
if track == nil {
return errors.New("attempted to send PLI for unknown track")
}
return up.pc.WriteRTCP([]rtcp.Packet{
&rtcp.PictureLossIndication{MediaSSRC: track.SSRC()},
})
}
func countMediaStreams(data string) (int, error) {
desc := sdp.NewJSEPSessionDescription(false)
err := desc.Unmarshal(data)
if err != nil {
return 0, err
}
return len(desc.MediaDescriptions), nil
}
func negotiate(c *client, id string, pc *webrtc.PeerConnection) error {
offer, err := pc.CreateOffer(nil)
if err != nil {
return err
}
err = pc.SetLocalDescription(offer)
if err != nil {
return err
}
return c.write(clientMessage{
Type: "offer",
Id: id,
Offer: &offer,
})
}
func sendICE(c *client, id string, candidate *webrtc.ICECandidate) error {
if candidate == nil {
return nil
}
cand := candidate.ToJSON()
return c.write(clientMessage{
Type: "ice",
Id: id,
Candidate: &cand,
})
}
func gotOffer(c *client, offer webrtc.SessionDescription, id string) error {
var err error
up, ok := c.up[id]
if !ok {
up, err = addUpConn(c, id)
if err != nil {
return err
}
}
if c.username != "" {
up.label = c.username
}
n, err := countMediaStreams(offer.SDP)
if err != nil {
log.Printf("Couldn't parse SDP: %v", err)
n = 2
}
up.streamCount = n
err = up.pc.SetRemoteDescription(offer)
if err != nil {
return err
}
answer, err := up.pc.CreateAnswer(nil)
if err != nil {
return err
}
err = up.pc.SetLocalDescription(answer)
if err != nil {
return err
}
return c.write(clientMessage{
Type: "answer",
Id: id,
Answer: &answer,
})
}
func gotAnswer(c *client, answer webrtc.SessionDescription, id string) error {
conn := getDownConn(c, id)
if conn == nil {
return protocolError("unknown id in answer")
}
err := conn.pc.SetRemoteDescription(answer)
if err != nil {
return err
}
return nil
}
func gotICE(c *client, candidate *webrtc.ICECandidateInit, id string) error {
var pc *webrtc.PeerConnection
down := getDownConn(c, id)
if down != nil {
pc = down.pc
} else {
up := getUpConn(c, id)
if up == nil {
return errors.New("unknown id in ICE")
}
pc = up.pc
}
return pc.AddICECandidate(*candidate)
}
func clientLoop(c *client, conn *websocket.Conn) error {
read := make(chan interface{}, 1)
go clientReader(conn, read, c.done)
defer func() {
if c.down != nil {
for id := range c.down {
c.write(clientMessage{
Type: "close",
Id: id,
})
delDownConn(c, id)
}
}
if c.up != nil {
for id := range c.up {
delUpConn(c, id)
}
}
}()
g := c.group
for _, cc := range g.getClients(c) {
cc.action(pushTracksAction{c})
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case m, ok := <-read:
if !ok {
return errors.New("reader died")
}
switch m := m.(type) {
case clientMessage:
err := handleClientMessage(c, m)
if err != nil {
return err
}
case error:
return m
}
case a := <-c.actionCh:
switch a := a.(type) {
case addTrackAction:
down, _, err :=
addDownTrack(
c, a.id, a.track,
a.remote)
if err != nil {
return err
}
if a.done {
err = negotiate(c, a.id, down.pc)
if err != nil {
return err
}
}
case delPCAction:
c.write(clientMessage{
Type: "close",
Id: a.id,
})
delDownConn(c, a.id)
case addLabelAction:
c.write(clientMessage{
Type: "label",
Id: a.id,
Value: a.label,
})
case pushTracksAction:
for _, u := range c.up {
var done bool
for i, p := range u.pairs {
done = i >= u.streamCount - 1
a.c.action(addTrackAction{
u.id, p.local, u,
done,
})
}
if done && u.label != "" {
a.c.action(addLabelAction{
u.id, u.label,
})
}
}
default:
log.Printf("unexpected action %T", a)
return errors.New("unexpected action")
}
case <-ticker.C:
sendRateUpdate(c)
}
}
}
func handleClientMessage(c *client, m clientMessage) error {
switch m.Type {
case "offer":
if m.Offer == nil {
return protocolError("null offer")
}
err := gotOffer(c, *m.Offer, m.Id)
if err != nil {
return err
}
case "answer":
if m.Answer == nil {
return protocolError("null answer")
}
err := gotAnswer(c, *m.Answer, m.Id)
if err != nil {
return err
}
case "close":
delUpConn(c, m.Id)
case "ice":
if m.Candidate == nil {
return protocolError("null candidate")
}
err := gotICE(c, m.Candidate, m.Id)
if err != nil {
log.Printf("ICE: %v", err)
}
case "chat":
clients := c.group.getClients(c)
for _, cc := range clients {
cc.write(m)
}
default:
log.Printf("unexpected message: %v", m.Type)
return protocolError("unexpected message")
}
return nil
}
func sendRateUpdate(c *client) {
for _, u := range c.up {
oldaudio := u.maxAudioBitrate
oldvideo := u.maxVideoBitrate
audio, video := updateBitrate(c.group, u)
if audio != oldaudio || video != oldvideo {
c.write(clientMessage{
Type: "maxbitrate",
Id: u.id,
AudioRate: int(audio),
VideoRate: int(video),
})
}
}
}
func clientReader(conn *websocket.Conn, read chan<- interface{}, done <-chan struct{}) {
defer close(read)
for {
var m clientMessage
err := conn.ReadJSON(&m)
if err != nil {
select {
case read <- err:
return
case <-done:
return
}
}
select {
case read <- m:
case <-done:
return
}
}
}
func clientWriter(conn *websocket.Conn, ch <-chan interface{}, done chan<- struct{}) {
defer func() {
close(done)
conn.Close()
}()
for {
m, ok := <-ch
if !ok {
break
}
err := conn.SetWriteDeadline(
time.Now().Add(2 * time.Second))
if err != nil {
return
}
switch m := m.(type) {
case clientMessage:
err := conn.WriteJSON(m)
if err != nil {
return
}
case closeMessage:
err := conn.WriteMessage(websocket.CloseMessage, m.data)
if err != nil {
return
}
default:
log.Printf("clientWiter: unexpected message %T", m)
return
}
}
}

10
go.mod Normal file
View File

@ -0,0 +1,10 @@
module sfu
go 1.13
require (
github.com/gorilla/websocket v1.4.2
github.com/pion/rtcp v1.2.1
github.com/pion/sdp v1.3.0
github.com/pion/webrtc/v2 v2.2.5
)

115
go.sum Normal file
View File

@ -0,0 +1,115 @@
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9 h1:tbuodUh2vuhOVZAdW3NEUvosFHUMJwUNl7jk/VSEiwc=
github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw=
github.com/marten-seemann/qtls v0.2.3 h1:0yWJ43C62LsZt08vuQJDK1uC1czUc3FJeCLPoNAI4vA=
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pion/datachannel v1.4.16 h1:dvuDC0IBMUDQvwO+gRu0Dv+W5j7rrgNpCmtheb6iYnc=
github.com/pion/datachannel v1.4.16/go.mod h1:gRGhxZv7X2/30Qxes4WEXtimKBXcwj/3WsDtBlHnvJY=
github.com/pion/dtls/v2 v2.0.0-rc.9 h1:wPb0JKmYoleAM2o8vQSPaUM+geJq7l0AdeUlPsg19ec=
github.com/pion/dtls/v2 v2.0.0-rc.9/go.mod h1:6eFkFvpo0T+odQ+39HFEtOO7LX5cUlFqXdSo4ucZtGg=
github.com/pion/dtls/v2 v2.0.0-rc.10 h1:WM+LVyR3f7hfxMLE0zhydwxSesboH/TXDnqv+32uiHo=
github.com/pion/dtls/v2 v2.0.0-rc.10/go.mod h1:VkY5VL2wtsQQOG60xQ4lkV5pdn0wwBBTzCfRJqXhp3A=
github.com/pion/ice v0.7.12 h1:Lsh4f0Uvh/vOCXSyj+w5C736LrKt66qAKeA2LFwSkn0=
github.com/pion/ice v0.7.12/go.mod h1:yLt/9LAJEZXFtnOBdpq5YGaOF9SsDjVGCvzF3MF4k5k=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.4 h1:O4vvVqr4DGX63vzmO6Fw9vpy3lfztVWHGCQfyw0ZLSY=
github.com/pion/mdns v0.0.4/go.mod h1:R1sL0p50l42S5lJs91oNdUL58nm0QHrhxnSegr++qC0=
github.com/pion/quic v0.1.1 h1:D951FV+TOqI9A0rTF7tHx0Loooqz+nyzjEyj8o3PuMA=
github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k=
github.com/pion/rtcp v1.2.1 h1:S3yG4KpYAiSmBVqKAfgRa5JdwBNj4zK3RLUa8JYdhak=
github.com/pion/rtcp v1.2.1/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM=
github.com/pion/rtp v1.3.2 h1:Yfzf1mU4Zmg7XWHitzYe2i+l+c68iO+wshzIUW44p1c=
github.com/pion/rtp v1.3.2/go.mod h1:q9wPnA96pu2urCcW/sK/RiDn597bhGoAQQ+y2fDwHuY=
github.com/pion/rtp v1.4.0 h1:EkeHEXKuJhZoRUxtL2Ie80vVg9gBH+poT9UoL8M14nw=
github.com/pion/rtp v1.4.0/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE=
github.com/pion/sctp v1.7.6 h1:8qZTdJtbKfAns/Hv5L0PAj8FyXcsKhMH1pKUCGisQg4=
github.com/pion/sctp v1.7.6/go.mod h1:ichkYQ5tlgCQwEwvgfdcAolqx1nHbYCxo4D7zK/K0X8=
github.com/pion/sdp v1.3.0 h1:21lpgEILHyolpsIrbCBagZaAPj4o057cFjzaFebkVOs=
github.com/pion/sdp v1.3.0/go.mod h1:ceA2lTyftydQTuCIbUNoH77aAt6CiQJaRpssA4Gee8I=
github.com/pion/sdp/v2 v2.3.6 h1:jmhawd6iJy6HeHlAhlXJAGYxnDCighvgeCexm4c3UVk=
github.com/pion/sdp/v2 v2.3.6/go.mod h1:+ZZf35r1+zbaWYiZLfPutWfx58DAWcGb2QsS3D/s9M8=
github.com/pion/srtp v1.3.1 h1:WNDLN41ST0P6cXRpzx97JJW//vChAEo1+Etdqo+UMnM=
github.com/pion/srtp v1.3.1/go.mod h1:nxEytDDGTN+eNKJ1l5gzOCWQFuksgijorsSlgEjc40Y=
github.com/pion/stun v0.3.3 h1:brYuPl9bN9w/VM7OdNzRSLoqsnwlyNvD9MVeJrHjDQw=
github.com/pion/stun v0.3.3/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M=
github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE=
github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8=
github.com/pion/transport v0.10.0 h1:9M12BSneJm6ggGhJyWpDveFOstJsTiQjkLf4M44rm80=
github.com/pion/transport v0.10.0/go.mod h1:BnHnUipd0rZQyTVB2SBGojFHT9CBt5C5TcsJSQGkvSE=
github.com/pion/turn/v2 v2.0.3 h1:SJUUIbcPoehlyZgMyIUbBBDhI03sBx32x3JuSIBKBWA=
github.com/pion/turn/v2 v2.0.3/go.mod h1:kl1hmT3NxcLynpXVnwJgObL8C9NaCyPTeqI2DcCpSZs=
github.com/pion/webrtc/v2 v2.2.5 h1:JHHv4fKeBJlVAziqq9QByCiH+g9Jnru9epmanRQqFG4=
github.com/pion/webrtc/v2 v2.2.5/go.mod h1:uiygdBNqK4PfZu2BxjzVV5xkzqiAMlKT4r4NAFXCyqc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw=
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

239
group.go Normal file
View File

@ -0,0 +1,239 @@
// Copyright (c) 2020 by Juliusz Chroboczek.
// This is not open source software. Copy it, and I'll break into your
// house and tell your three year-old that Santa doesn't exist.
package main
import (
"log"
"sync"
"github.com/pion/webrtc/v2"
)
type trackPair struct {
remote, local *webrtc.Track
}
type upConnection struct {
id string
label string
pc *webrtc.PeerConnection
maxAudioBitrate uint32
maxVideoBitrate uint32
streamCount int
pairs []trackPair
}
type downConnection struct {
id string
pc *webrtc.PeerConnection
remote *upConnection
maxBitrate uint32
}
type client struct {
group *group
id string
username string
done chan struct{}
writeCh chan interface{}
writerDone chan struct{}
actionCh chan interface{}
down map[string]*downConnection
up map[string]*upConnection
}
type group struct {
name string
public bool
mu sync.Mutex
clients []*client
}
type delPCAction struct {
id string
}
type addTrackAction struct {
id string
track *webrtc.Track
remote *upConnection
done bool
}
type addLabelAction struct {
id string
label string
}
type getUpAction struct {
ch chan<- string
}
type pushTracksAction struct {
c *client
}
var groups struct {
mu sync.Mutex
groups map[string]*group
api *webrtc.API
}
func addGroup(name string) (*group, error) {
groups.mu.Lock()
defer groups.mu.Unlock()
if groups.groups == nil {
groups.groups = make(map[string]*group)
m := webrtc.MediaEngine{}
m.RegisterCodec(webrtc.NewRTPVP8Codec(
webrtc.DefaultPayloadTypeVP8, 90000))
m.RegisterCodec(webrtc.NewRTPOpusCodec(
webrtc.DefaultPayloadTypeOpus, 48000))
groups.api = webrtc.NewAPI(
webrtc.WithMediaEngine(m),
)
}
g := groups.groups[name]
if g == nil {
g = &group{
name: name,
}
groups.groups[name] = g
}
return g, nil
}
func delGroup(name string) bool {
groups.mu.Lock()
defer groups.mu.Unlock()
g := groups.groups[name]
if g == nil {
return true
}
if len(g.clients) != 0 {
return false
}
delete(groups.groups, name)
return true
}
type userid struct {
id string
username string
}
func addClient(name string, client *client) (*group, []userid, error) {
g, err := addGroup(name)
if err != nil {
return nil, nil, err
}
var users []userid
g.mu.Lock()
defer g.mu.Unlock()
for _, c := range g.clients {
users = append(users, userid{c.id, c.username})
}
g.clients = append(g.clients, client)
return g, users, nil
}
func delClient(c *client) {
c.group.mu.Lock()
defer c.group.mu.Unlock()
g := c.group
for i, cc := range g.clients {
if cc == c {
g.clients =
append(g.clients[:i], g.clients[i+1:]...)
c.group = nil
return
}
}
log.Printf("Deleting unknown client")
c.group = nil
}
func (g *group) getClients(except *client) []*client {
g.mu.Lock()
defer g.mu.Unlock()
clients := make([]*client, 0, len(g.clients))
for _, c := range g.clients {
if c != except {
clients = append(clients, c)
}
}
return clients
}
func (g *group) Range(f func(c *client) bool) {
g.mu.Lock()
defer g.mu.Unlock()
for _, c := range g.clients {
ok := f(c)
if(!ok){
break;
}
}
}
type writerDeadError int
func (err writerDeadError) Error() string {
return "client writer died"
}
func (c *client) write(m clientMessage) error {
select {
case c.writeCh <- m:
return nil
case <-c.writerDone:
return writerDeadError(0)
}
}
type clientDeadError int
func (err clientDeadError) Error() string {
return "client dead"
}
func (c *client) action(m interface{}) error {
select {
case c.actionCh <- m:
return nil
case <-c.done:
return clientDeadError(0)
}
}
type publicGroup struct {
Name string `json:"name"`
ClientCount int `json:"clientCount"`
}
func getPublicGroups() []publicGroup {
gs := make([]publicGroup, 0)
groups.mu.Lock()
defer groups.mu.Unlock()
for _, g := range groups.groups {
if g.public {
gs = append(gs, publicGroup{
Name: g.name,
ClientCount: len(g.clients),
})
}
}
return gs
}

108
sfu.go Normal file
View File

@ -0,0 +1,108 @@
// Copyright (c) 2020 by Juliusz Chroboczek.
// This is not open source software. Copy it, and I'll break into your
// house and tell your three year-old that Santa doesn't exist.
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var httpAddr string
var staticRoot string
var dataDir string
var iceFilename string
func main() {
flag.StringVar(&httpAddr, "http", ":8443", "web server `address`")
flag.StringVar(&staticRoot, "static", "./static/",
"web server root `directory`")
flag.StringVar(&dataDir, "data", "./data/",
"data `directory`")
flag.Parse()
iceFilename = filepath.Join(staticRoot, "ice-servers.json")
http.Handle("/", mungeHandler{http.FileServer(http.Dir(staticRoot))})
http.HandleFunc("/group/",
func(w http.ResponseWriter, r *http.Request) {
mungeHeader(w)
http.ServeFile(w, r, staticRoot+"/sfu.html")
})
http.HandleFunc("/ws", wsHandler)
http.HandleFunc("/public-groups.json", publicHandler)
go func() {
server := &http.Server{
Addr: httpAddr,
ReadTimeout: 60 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
var err error
log.Printf("Listening on %v", httpAddr)
err = server.ListenAndServeTLS(
filepath.Join(dataDir, "cert.pem"),
filepath.Join(dataDir, "key.pem"),
)
log.Fatalf("ListenAndServeTLS: %v", err)
}()
terminate := make(chan os.Signal, 1)
signal.Notify(terminate, syscall.SIGINT)
<-terminate
}
func mungeHeader(w http.ResponseWriter) {
w.Header().Add("Content-Security-Policy",
"connect-src ws: wss: 'self'; img-src data: 'self'; default-src 'self'")
}
type mungeHandler struct {
h http.Handler
}
func (h mungeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
mungeHeader(w)
h.h.ServeHTTP(w, r)
}
func publicHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
w.Header().Set("cache-control", "no-cache")
if r.Method == "HEAD" {
return
}
g := getPublicGroups()
e := json.NewEncoder(w)
e.Encode(g)
return
}
var upgrader websocket.Upgrader
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Websocket upgrade: %v", err)
return
}
go func() {
err := startClient(conn)
if err != nil {
log.Printf("client: %v", err)
}
}()
}

15
static/common.css Normal file
View File

@ -0,0 +1,15 @@
body {
font: 14px "Lato", Arial, sans-serif;
}
h1 {
font-size: 160%;
}
.signature {
border-top: solid;
margin-top: 2em;
padding-top: 0em;
border-width: thin;
clear: both;
}

32
static/index.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SFU</title>
<link rel="stylesheet" href="/common.css">
<link rel="stylesheet" href="/mainpage.css">
<link rel="author" href="https://www.irif.fr/~jch/"/>
</head>
<body>
<h1>SFU</h1>
<form id="groupform">
<label for="group">Group:</label>
<input id="group" type="text" name="group"/>
<input type="submit" value="Join"/><br/>
</form>
<div id="public-groups" class="groups">
<h2>Public groups</h2>
<table id="public-groups-table"></table>
</div>
<footer class="signature"><p><a href="https://www.irif.fr/~jch/software/sfu/">Unnamed SFU</a> by <a href="https://www.irif.fr/~jch/" rel="author">Juliusz Chroboczek</a></footer>
<script src="/mainpage.js" defer></script>
</body>
</html>

8
static/mainpage.css Normal file
View File

@ -0,0 +1,8 @@
.groups {
}
.nogroups {
display: none;
}

54
static/mainpage.js Normal file
View File

@ -0,0 +1,54 @@
// Copyright (c) 2019-2020 by Juliusz Chroboczek.
// This is not open source software. Copy it, and I'll break into your
// house and tell your three year-old that Santa doesn't exist.
'use strict';
document.getElementById('groupform').onsubmit = function(e) {
e.preventDefault();
let group = document.getElementById('group').value.trim();
location.href = '/group/' + group;
}
async function listPublicGroups() {
let div = document.getElementById('public-groups');
let table = document.getElementById('public-groups-table');
let l;
try {
l = await (await fetch('/public-groups.json')).json();
} catch(e) {
console.error(e);
l = [];
}
if (l.length === 0) {
table.textContent = '(No groups found.)';
div.classList.remove('groups');
div.classList.add('nogroups');
return;
}
div.classList.remove('nogroups');
div.classList.add('groups');
for(let i = 0; i < l.length; i++) {
let group = l[i];
let tr = document.createElement('tr');
let td = document.createElement('td');
let a = document.createElement('a');
a.textContent = group.name;
a.href = '/group/' + encodeURIComponent(group.name);
td.appendChild(a);
tr.appendChild(td);
let td2 = document.createElement('td');
td2.textContent = `(${group.clientCount} clients)`;
tr.appendChild(td2);
table.appendChild(tr);
}
}
listPublicGroups();

181
static/sfu.css Normal file
View File

@ -0,0 +1,181 @@
#title {
text-align: center;
}
h1 {
white-space: nowrap;
}
#header {
margin-left: 2%;
}
#statdiv {
white-space: nowrap;
margin-bottom: 2pt;
}
#errspan {
margin-left: 1em;
}
.connected {
color: green;
}
.disconnected {
background-color: red;
font-weight: bold;
}
.userform {
display: inline
}
.userform-invisible {
display: none;
}
.disconnect-invisible {
display: none;
}
.error {
color: red;
font-weight: bold;
}
.noerror {
display: none;
}
#main {
display: flex;
}
#users {
width: 5%;
margin-left: 2%;
border: 1px solid;
}
#anonymous-users {
white-space: nowrap;
}
#chatbox {
width: 100%;
}
#chat {
display: flex;
width: 20%;
margin-left: 0.3em;
border: 1px solid;
height: 85vh;
}
#inputform {
display: flex;
}
#box {
height: 95%;
overflow: auto;
}
.message, message-me {
margin: 0 0.5em 0 0.5em;
}
.message-user {
font-weight: bold;
}
.message-content {
line-height: 1.5;
margin-left: 1em;
}
.message-me-asterisk {
margin-right: 0.5em;
}
.message-me-user {
font-weight: bold;
margin-right: 0.5em;
}
.message-me-content {
}
#input {
width: 100%;
border: none;
resize: none;
overflow-y: hidden;
}
#input:focus {
outline: none;
}
#inputbutton {
background-color: white;
border: none;
margin-right: 0.2em;
font-size: 1.5em;
}
#inputbutton:focus {
outline: none;
}
#resizer {
width: 8px;
margin-left: -8px;
z-index: 1000;
}
#resizer:hover {
cursor: ew-resize;
}
#peers {
margin-left: 1%;
margin-right: 1%;
white-space: nowrap;
display: flex;
flex-wrap: wrap;
margin-bottom: 4px;
}
.peer {
display: flex;
flex-direction: column;
margin-right: 5px;
margin-left: 5px;
margin-bottom: 10px;
max-height: 50%;
}
.media {
height: 400px;
margin: auto;
min-width: 4em;
}
.label {
text-align: center;
height: 2em;
margin-top: 5px;
}
#inputform {
width: 100%;
}
#input {
width: 85%;
border: 1px solid;
}

57
static/sfu.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SFU</title>
<link rel="stylesheet" type="text/css" href="/common.css"/>
<link rel="stylesheet" type="text/css" href="/sfu.css"/>
<link rel="author" href="https://www.irif.fr/~jch/"/>
</head>
<body>
<h1 id="title">SFU</h1>
<div id="header">
<div id="statdiv">
<span id="statspan"></span>
<form id="userform" class="userform">
<label for="username">Username:</label>
<input id="username" type="text" name="username"
autocomplete="username"/>
<label for="password">Password:</label>
<input id="password" type="password" name="password"
autocomplete="current-password"/>
<input id="connectbutton" type="submit" value="Connect" disabled/>
</form>
<input id="disconnectbutton" class="disconnect-invisible"
type="submit" value="Disconnect"/>
<span id="errspan"></span>
</div>
<div id="optionsdiv">
<label for="presenterbox">Present:</label>
<input id="presenterbox" type="checkbox"/>
<label for="sharebox">Share screen:</label>
<input id="sharebox" type="checkbox"/>
</div>
</div>
<div id="main">
<div id="users"></div>
<div id="chat">
<div id="chatbox">
<div id="box"></div>
<form id="inputform">
<textarea id="input"></textarea>
<input id="inputbutton" type="submit" value="&#10148;"/>
</form>
</div>
<div id="resizer">
</div>
</div>
<div id="peers"></div>
</div>
<script src="/sfu.js" defer></script>
</body>
</html>

794
static/sfu.js Normal file
View File

@ -0,0 +1,794 @@
// Copyright (c) 2020 by Juliusz Chroboczek.
// This is not open source software. Copy it, and I'll break into your
// house and tell your three year-old that Santa doesn't exist.
'use strict';
let myid;
let group;
let socket;
let up = {}, down = {};
let iceServers = [];
function toHex(array) {
let a = new Uint8Array(array);
let s = '';
function hex(x) {
let h = x.toString(16);
if(h.length < 2)
h = '0' + h;
return h;
}
return a.reduce((x, y) => x + hex(y), '');
}
function randomid() {
let a = new Uint8Array(16);
crypto.getRandomValues(a);
return toHex(a);
}
function Connection(id, pc) {
this.id = id;
this.label = null;
this.pc = pc;
this.stream = null;
this.iceCandidates = [];
}
Connection.prototype.close = function() {
this.pc.close();
send({
type: 'close',
id: this.id,
});
}
function setUserPass(username, password) {
window.sessionStorage.setItem(
'userpass',
JSON.stringify({username: username, password: password}),
);
}
function getUserPass() {
let userpass = window.sessionStorage.getItem('userpass');
if(!userpass)
return null;
return JSON.parse(userpass);
}
function getUsername() {
let userpass = getUserPass();
if(!userpass)
return null;
return userpass.username;
}
function setConnected(connected) {
let statspan = document.getElementById('statspan');
let userform = document.getElementById('userform');
let diconnect = document.getElementById('disconnectbutton');
if(connected) {
statspan.textContent = 'Connected';
statspan.classList.remove('disconnected');
statspan.classList.add('connected');
userform.classList.add('userform-invisible');
userform.classList.remove('userform');
disconnectbutton.classList.remove('disconnect-invisible');
} else {
let userpass = getUserPass();
document.getElementById('username').value =
userpass ? userpass.username : '';
document.getElementById('password').value =
userpass ? userpass.password : '';
statspan.textContent = 'Disconnected';
statspan.classList.remove('connected');
statspan.classList.add('disconnected');
userform.classList.add('userform');
userform.classList.remove('userform-invisible');
disconnectbutton.classList.add('disconnect-invisible');
}
}
document.getElementById('presenterbox').onchange = function(e) {
e.preventDefault();
setLocalMedia();
}
document.getElementById('sharebox').onchange = function(e) {
e.preventDefault();
setShareMedia();
}
let localMediaId = null;
async function setLocalMedia() {
if(!getUserPass())
return;
if(!document.getElementById('presenterbox').checked) {
if(localMediaId) {
up[localMediaId].close();
delete(up[localMediaId]);
delMedia(localMediaId)
localMediaId = null;
}
return;
}
if(!localMediaId) {
let constraints = {audio: true, video: true};
let opts = {video: true, audio: true};
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch(e) {
console.error(e);
return;
}
localMediaId = await newUpStream();
let c = up[localMediaId];
c.stream = stream;
stream.getTracks().forEach(t => {
c.pc.addTrack(t, stream);
});
await setMedia(localMediaId);
}
}
let shareMediaId = null;
async function setShareMedia() {
if(!getUserPass())
return;
if(!document.getElementById('sharebox').checked) {
if(shareMediaId) {
up[shareMediaId].close();
delete(up[shareMediaId]);
delMedia(shareMediaId)
shareMediaId = null;
}
return;
}
if(!shareMediaId) {
let constraints = {audio: true, video: true};
let opts = {video: true, audio: true};
let stream = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({});
} catch(e) {
console.error(e);
return;
}
shareMediaId = await newUpStream();
let c = up[shareMediaId];
c.stream = stream;
stream.getTracks().forEach(t => {
c.pc.addTrack(t, stream);
});
await setMedia(shareMediaId);
}
}
function setMedia(id) {
let mine = true;
let c = up[id];
if(!c) {
c = down[id];
mine = false;
}
if(!c)
throw new Error('Unknown connection');
let peersdiv = document.getElementById('peers');
let div = document.getElementById('peer-' + id);
if(!div) {
div = document.createElement('div');
div.id = 'peer-' + id;
div.classList.add('peer');
peersdiv.appendChild(div);
}
let media = document.getElementById('media-' + id);
if(!media) {
media = document.createElement('video');
media.id = 'media-' + id;
media.classList.add('media');
media.autoplay = true;
media.playsinline = true;
media.controls = true;
if(mine)
media.muted = true;
div.appendChild(media);
}
let label = document.getElementById('label-' + id);
if(!label) {
label = document.createElement('div');
label.id = 'label-' + id;
label.classList.add('label');
div.appendChild(label)
}
media.srcObject = c.stream;
setLabel(id);
}
function delMedia(id) {
let mediadiv = document.getElementById('peers');
let peer = document.getElementById('peer-' + id);
let media = document.getElementById('media-' + id);
media.srcObject = null;
mediadiv.removeChild(peer);
}
function setLabel(id) {
let label = document.getElementById('label-' + id);
if(!label)
return;
let l = down[id] ? down[id].label : null;
label.textContent = l ? l : '';
}
function serverConnect() {
if(socket) {
socket.close(1000, 'Reconnecting');
socket = null;
setConnected(false);
}
try {
socket = new WebSocket(
`ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`,
);
} catch(e) {
console.error(e);
setConnected(false);
return Promise.reject(e);
}
return new Promise((resolve, reject) => {
socket.onerror = function(e) {
console.error(e);
reject(e.error ? e.error : e);
};
socket.onopen = function(e) {
resetUsers();
setConnected(true);
let up = getUserPass();
send({
type: 'handshake',
id: myid,
group: group,
username: up.username,
password: up.password,
});
resolve();
};
socket.onclose = function(e) {
setConnected(false);
document.getElementById('presenterbox').checked = false;
setLocalMedia();
document.getElementById('sharebox').checked = false;
setShareMedia();
reject(new Error('websocket close ' + e.code + ' ' + e.reason));
};
socket.onmessage = function(e) {
let m = JSON.parse(e.data);
switch(m.type) {
case 'offer':
gotOffer(m.id, m.offer);
break;
case 'answer':
gotAnswer(m.id, m.answer);
break;
case 'close':
gotClose(m.id);
break;
case 'ice':
gotICE(m.id, m.candidate);
break;
case 'maxbitrate':
setMaxBitrate(m.id, m.audiorate, m.videorate);
break;
case 'label':
gotLabel(m.id, m.value);
break;
case 'user':
gotUser(m.id, m.username, m.del);
break;
case 'chat':
addToChatbox(m.id, m.username, m.value, m.me);
break;
default:
console.warn('Unexpected server message', m.type);
return;
}
};
});
}
async function gotOffer(id, offer) {
let c = down[id];
if(!c) {
let pc = new RTCPeerConnection({
iceServers: iceServers,
});
c = new Connection(id, pc);
down[id] = c;
c.pc.onicecandidate = function(e) {
if(!e.candidate)
return;
send({type: 'ice',
id: id,
candidate: e.candidate,
});
}
c.pc.ontrack = function(e) {
c.stream = e.streams[0];
setMedia(id);
}
}
await c.pc.setRemoteDescription(offer);
await addIceCandidates(c);
let answer = await c.pc.createAnswer();
if(!answer)
throw new Error("Didn't create answer")
await c.pc.setLocalDescription(answer);
send({
type: 'answer',
id: id,
answer: answer,
});
}
function gotLabel(id, label) {
let c = down[id];
if(!c)
throw new Error('Got label for unknown id');
c.label = label;
setLabel(id);
}
async function gotAnswer(id, answer) {
let c = up[id];
if(!c)
throw new Error('unknown up stream');
await c.pc.setRemoteDescription(answer);
await addIceCandidates(c);
}
function gotClose(id) {
let c = down[id];
if(!c)
throw new Error('unknown down stream');
delete(down[id]);
c.close();
delMedia(id);
}
async function gotICE(id, candidate) {
let conn = up[id];
if(!conn)
conn = down[id];
if(!conn)
throw new Error('unknown stream');
if(conn.pc.remoteDescription)
await conn.pc.addIceCandidate(candidate).catch(console.warn);
else
conn.iceCandidates.push(candidate)
}
let maxaudiorate, maxvideorate;
async function setMaxBitrate(id, audio, video) {
let conn = up[id];
if(!conn)
throw new Error("Setting bitrate of unknown id");
let senders = conn.pc.getSenders();
for(let i = 0; i < senders.length; i++) {
let s = senders[i];
if(!s.track)
return;
let p = s.getParameters();
let bitrate;
if(s.track.kind == 'audio')
bitrate = audio;
else if(s.track.kind == 'video')
bitrate = video;
for(let j = 0; j < p.encodings.length; j++) {
let e = p.encodings[j];
if(bitrate)
e.maxBitrate = bitrate;
else
delete(e.maxBitrate);
await s.setParameters(p);
}
}
}
async function addIceCandidates(conn) {
let promises = []
conn.iceCandidates.forEach(c => {
promises.push(conn.pc.addIceCandidate(c).catch(console.warn));
});
conn.iceCandidates = [];
return await Promise.all(promises);
}
function send(m) {
if(!m)
throw(new Error('Sending null message'));
return socket.send(JSON.stringify(m))
}
let users = {};
function addUser(id, name) {
if(!name)
name = null;
if(id in users)
throw new Error('Duplicate user id');
users[id] = name;
let div = document.getElementById('users');
let anon = document.getElementById('anonymous-users');
let user = document.createElement('div');
user.id = 'user-' + id;
user.textContent = name ? name : '(anon)';
div.appendChild(user);
}
function delUser(id, name) {
if(!name)
name = null;
if(!id in users)
throw new Error('Unknown user id');
if(users[id] !== name)
throw new Error('Inconsistent user name');
delete(users[id]);
let div = document.getElementById('users');
let user = document.getElementById('user-' + id);
div.removeChild(user);
}
function resetUsers() {
for(let id in users)
delUser(id, users[id]);
}
function gotUser(id, name, del) {
if(del)
delUser(id, name);
else
addUser(id, name);
}
const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\+~#=?]+[-a-zA-Z0-9@:%/_\+~#=]/g;
function formatLine(line) {
let r = new RegExp(urlRegexp);
let result = [];
let pos = 0;
while(true) {
let m = r.exec(line);
if(!m)
break;
result.push(document.createTextNode(line.slice(pos, m.index)));
let a = document.createElement('a');
a.href = m[0];
a.textContent = m[0];
a.target = '_blank';
a.rel = 'noreferrer noopener';
result.push(a);
pos = m.index + m[0].length;
}
result.push(document.createTextNode(line.slice(pos)));
return result;
}
function formatLines(lines) {
let elts = [];
if(lines.length > 0)
elts = formatLine(lines[0]);
for(let i = 1; i < lines.length; i++) {
elts.push(document.createElement('br'));
elts = elts.concat(formatLine(lines[i]));
}
let elt = document.createElement('p');
elts.forEach(e => elt.appendChild(e));
return elt;
}
let lastMessage = {};
function addToChatbox(peerId, nick, message, me){
let container = document.createElement('div');
container.classList.add('message');
if(!me) {
let p = formatLines(message.split('\n'));
if (lastMessage.nick !== nick || lastMessage.peerId !== peerId) {
let user = document.createElement('p');
user.textContent = nick;
user.classList.add('message-user');
container.appendChild(user);
}
p.classList.add('message-content');
container.appendChild(p);
lastMessage.nick = nick;
lastMessage.peerId = peerId;
} else {
let asterisk = document.createElement('span');
asterisk.textContent = '*';
asterisk.classList.add('message-me-asterisk');
let user = document.createElement('span');
user.textContent = nick;
user.classList.add('message-me-user');
let content = document.createElement('span');
formatLine(message).forEach(elt => {
content.appendChild(elt);
});
content.classList.add('message-me-content');
container.appendChild(asterisk);
container.appendChild(user);
container.appendChild(content);
container.classList.add('message-me');
delete(lastMessage.nick);
delete(lastMessage.peerId);
}
document.getElementById('box').appendChild(container);
if(box.scrollHeight > box.clientHeight) {
box.scrollTop = box.scrollHeight - box.clientHeight;
}
return message;
}
function handleInput() {
let username = getUsername();
if(!username) {
displayError("Sorry, you're anonymous, you cannot chat");
return;
}
let input = document.getElementById('input');
let data = input.value;
input.value = '';
let message, me;
if(data === '')
return;
if(data.charAt(0) === '/') {
if(data.charAt(1) === '/') {
message = data.substring(1);
me = false;
} else {
let space, cmd, rest;
space = data.indexOf(' ');
if(space < 0) {
cmd = data;
rest = '';
} else {
cmd = data.slice(0, space);
rest = data.slice(space + 1).trim();
}
switch(cmd) {
case '/nick':
setNick(rest);
storeNick(rest);
return;
case '/me':
message = rest;
me = true;
break;
default:
displayError('Uknown command ' + cmd);
return;
}
}
} else {
message = data;
me = false;
}
addToChatbox(myid, username, message, me);
send({
type: 'chat',
username: username,
value: message,
me: me,
});
}
document.getElementById('inputform').onsubmit = function(e) {
e.preventDefault();
handleInput();
};
document.getElementById('input').onkeypress = function(e) {
if(e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
e.preventDefault();
handleInput();
}
}
function chatResizer(e) {
e.preventDefault();
let chat = document.getElementById('chat');
let start_x = e.clientX;
let start_width = parseFloat(
document.defaultView.getComputedStyle(chat).width.replace('px', ''),
);
let inputbutton = document.getElementById('inputbutton');
function start_drag(e) {
let width = start_width + e.clientX - start_x;
if(width < 40)
inputbutton.style.display = 'none';
else
inputbutton.style.display = 'inline';
chat.style.width = width + 'px';
}
function stop_drag(e) {
document.documentElement.removeEventListener(
'mousemove', start_drag, false,
);
document.documentElement.removeEventListener(
'mouseup', stop_drag, false,
);
}
document.documentElement.addEventListener(
'mousemove', start_drag, false,
);
document.documentElement.addEventListener(
'mouseup', stop_drag, false,
);
}
document.getElementById('resizer').addEventListener('mousedown', chatResizer, false);
async function newUpStream() {
let id = randomid();
if(up[id])
throw new Error('Eek!');
let pc = new RTCPeerConnection({
iceServers: iceServers,
});
if(!pc)
throw new Error("Couldn't create peer connection")
up[id] = new Connection(id, pc);
pc.onnegotiationneeded = e => negotiate(id);
pc.onicecandidate = function(e) {
if(!e.candidate)
return;
send({type: 'ice',
id: id,
candidate: e.candidate,
});
}
pc.ontrack = console.error;
return id;
}
async function negotiate(id) {
let c = up[id];
if(!c)
throw new Error('unknown connection');
let offer = await c.pc.createOffer({});
if(!offer)
throw(new Error("Didn't create offer"));
await c.pc.setLocalDescription(offer);
send({
type: 'offer',
id: id,
offer: offer,
});
}
let errorTimeout = null;
function setErrorTimeout(ms) {
if(errorTimeout) {
clearTimeout(errorTimeout);
errorTimeout = null;
}
if(ms) {
errorTimeout = setTimeout(clearError, ms);
}
}
function displayError(message) {
let errspan = document.getElementById('errspan');
errspan.textContent = message;
errspan.classList.remove('noerror');
errspan.classList.add('error');
setErrorTimeout(8000);
}
function displayWarning(message) {
// don't overwrite real errors
if(!errorTimeout)
return displayError(message);
}
function clearError() {
let errspan = document.getElementById('errspan');
errspan.textContent = '';
errspan.classList.remove('error');
errspan.classList.add('noerror');
setErrorTimeout(null);
}
async function getIceServers() {
let r = await fetch('/ice-servers.json');
if(!r.ok)
throw new Error("Couldn't fetch ICE servers: " +
r.status + ' ' + r.statusText);
let servers = await r.json();
if(!(servers instanceof Array))
throw new Error("couldn't parse ICE servers");
iceServers = servers;
}
async function doConnect() {
await serverConnect();
await setLocalMedia();
await setShareMedia();
}
document.getElementById('userform').onsubmit = async function(e) {
e.preventDefault();
let username = document.getElementById('username').value.trim();
let password = document.getElementById('password').value;
setUserPass(username, password);
await doConnect();
}
document.getElementById('disconnectbutton').onclick = function(e) {
socket.close();
}
function start() {
group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, ''));
let title = document.getElementById('title');
if(group !== '')
title.textContent = group.charAt(0).toUpperCase() + group.slice(1);
myid = randomid();
getIceServers().catch(console.error).then(c => {
document.getElementById('connectbutton').disabled = false;
}).then(c => {
let userpass = getUserPass();
if(userpass)
doConnect();
});
}
start();